mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 11:56:50 -06:00
M9: team libraries on the server; swap library (#117)
A file's library can be published to figcraft-server and enabled in other files by a share link (view access is enough); placing, styles, variables and library updates with overrides kept work as for local libraries, with a background check while the Assets panel is open. library.swap re-points instances, styles and variables to another library by name (one undo step, unmatched items listed). The server keeps per-file usage reports (library.usage).
This commit is contained in:
Generated
+1
@@ -1618,6 +1618,7 @@ dependencies = [
|
||||
"figcraft-collab-net",
|
||||
"figcraft-doc",
|
||||
"figcraft-engine",
|
||||
"figcraft-format",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
+11
-5
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~28 points and ~200–365 h away (one Opus 5.5 agent; ~40–80 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
|
||||
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
|
||||
@@ -25,7 +25,7 @@ start with [`docs/gaps.md`](docs/gaps.md).
|
||||
|
||||
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|
||||
|---|---|---|---|
|
||||
| Full target (ready for real work) | ~47% | ~365–690 h | multiplayer (45–90), localization (60–100), plugins (25–55), the interaction pass (40–60), `.fig` import (30–60), GPU canvas (30–60), AI (30–60) |
|
||||
| Full target (ready for real work) | ~47% | ~360–680 h | multiplayer (45–90), localization (60–100), plugins (25–55), the interaction pass (40–60), `.fig` import (30–60), GPU canvas (30–60), AI (30–60) |
|
||||
| Mainstream practitioner | ~43% | ~170–315 h | core editor depth incl. `.fig` import (88–171), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) |
|
||||
| Essentials user | ~53% | ~85–160 h | `.fig` import so files people send open (30–60), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) |
|
||||
|
||||
@@ -59,7 +59,7 @@ the localization layer with Figma's own languages (20–35 h).
|
||||
| Features (depth) | 57% (core editor 67%) | 68–129 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) |
|
||||
| File formats | 40% (no `.fig`) | 30–60 | [file-format-parity](docs/file-format-parity.md) |
|
||||
| Collaboration | 12% (core in; live sessions with document sync, cursors and text carets, the browser joins, invite links open the app; a self-hosted server with edit and view links; preview) | 55–110 | [collaboration-parity](docs/collaboration-parity.md) |
|
||||
| Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, the browser joins, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 49–98 | [collaboration-parity](docs/collaboration-parity.md) |
|
||||
| Stability | 55% | 20–40 | [gaps](docs/gaps.md) |
|
||||
| Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Hardware | 50% | 15–30 | [hardware-parity](docs/hardware-parity.md) |
|
||||
@@ -90,7 +90,7 @@ the localization layer with Figma's own languages (20–35 h).
|
||||
| Comments, versions, misc | 80% | 58% | 4–8 | [collaboration-parity](docs/collaboration-parity.md) |
|
||||
| Multiplayer & sharing | 15% | 5% | 45–90 | [collaboration-parity](docs/collaboration-parity.md) |
|
||||
| Plugins & widgets | 35% | 20% | 25–55 | [plugins](docs/plugins.md) |
|
||||
| Team libraries | 40% | 25% | 10–20 | [components-parity](docs/components-parity.md) |
|
||||
| Team libraries | 70% | 40% | 4–8 | [components-parity](docs/components-parity.md) |
|
||||
| Figma Draw extras | 45% | 30% | 10–18 | [gaps](docs/gaps.md) |
|
||||
| AI features | 0% | 0% | 30–60 | [gaps](docs/gaps.md) |
|
||||
|
||||
@@ -123,10 +123,15 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
2. Real-file corpus and measured fidelity: 15–30 h.
|
||||
3. Tool-by-tool interaction pass against Figma: 40–60 h.
|
||||
4. Localization layer, then ja, ko, es, pt-BR, fr, de: 35–55 h.
|
||||
5. Multiplayer (M9): accounts, teams and TLS in the desktop app on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view landed): 45–90 h.
|
||||
5. Multiplayer (M9): accounts, teams and TLS in the desktop app on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view, team libraries on the server landed): 45–90 h.
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-11** · Team libraries on `figcraft-server` (gap 10): Assets ▸ Libraries ▸ Publish to server keeps
|
||||
the file's library on the server under the file (per-asset versions; the server's version is followed); other files
|
||||
enable it by pasting a share link (a view link is enough), get "Library updates available" from a background check
|
||||
and accept with overrides kept; the server counts which files use which components (Show usage, `library.usage`);
|
||||
`library.swap` re-points instances, styles and variables to another library by names. Team libraries 70%.
|
||||
- **2026-10-11** · Animated GIF fills (first frame on the canvas and in exports, played in the
|
||||
presenter and the preview, waking the window only for the next frame) and TIFF images (gap 12).
|
||||
- **2026-10-11** · The last three menu items got commands: File ▸ Open recent (kept in the
|
||||
@@ -166,6 +171,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Team libraries on `figcraft-server` landed (gap 10): a file's library is published to the server under the file and enabled in other files by a share link (per-asset versions; updates noticed in the background and accepted with overrides kept), the server counts which files use which components, and a whole library can be swapped for another by names; Team libraries breadth 40% → 70%, ready 25% → 40%, 10–20 h → 4–8 h; collaboration 12% → 13%, 55–110 h → 49–98 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–715 h |
|
||||
| 2026-10-11 | minor | Branches landed (gap 17): File ▸ Create branch… copies the file into a branch tab that remembers its base; Review and merge shows both sides' changes (layers with Inspect-style property changes, styles, variables, comments) and the conflicts (the same property changed on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merging is one undo step and archives the branch; Update from main; branches are saved in the `.figcraft` file. Comments, versions, find, misc breadth 80% → 85%, ready 58% → 60%, 4–8 h → 2–5 h; collaboration 12% → 13%; menu breadth 204/204 (the catalog gained File ▸ Create branch…); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~378–722 h |
|
||||
| 2026-10-11 | minor | Animated GIF fills and TIFF images (gap 12, without video and HEIC): GIF fills keep the original file, show the first frame on the canvas and in exports and play in the presenter and the preview (frames decoded on first play, at most 500 frames and 128 MB; always looping; the window wakes only for a GIF's next frame); TIFF images open, import, place and paste. Paint breadth 85% → 87%, 5–9 h → 4–7 h; gap 12 6–12 h → 5–10 h (video playback and HEIC left, both needing non-Rust decoders); feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
|
||||
| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
|
||||
|
||||
@@ -19,6 +19,8 @@ workspace = true
|
||||
[dependencies]
|
||||
figcraft-collab = { workspace = true }
|
||||
figcraft-collab-net = { workspace = true }
|
||||
# Checking a team library before keeping it (read as files using it read it).
|
||||
figcraft-format = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -22,6 +22,8 @@ pub const SNAPSHOT: &str = "snapshot.bin";
|
||||
pub const LOG: &str = "log.bin";
|
||||
pub const META: &str = "meta.json";
|
||||
pub const LINKS: &str = "links.json";
|
||||
pub const LIBRARY: &str = "library.bin";
|
||||
pub const USAGE: &str = "usage.json";
|
||||
|
||||
const SNAP_MAGIC: &[u8; 4] = b"FCSN";
|
||||
const LOG_MAGIC: &[u8; 4] = b"FCLG";
|
||||
|
||||
@@ -73,6 +73,8 @@ pub fn start(cfg: &Config) -> Result<Running, String> {
|
||||
#[cfg(test)]
|
||||
mod cli_tests;
|
||||
#[cfg(test)]
|
||||
mod library_tests;
|
||||
#[cfg(test)]
|
||||
mod server_tests;
|
||||
#[cfg(test)]
|
||||
mod store_tests;
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
//! Team libraries end to end over loopback, with the server in-process on a temporary data
|
||||
//! directory: a file publishes its library to the server, another file enables it with a view
|
||||
//! link, places a component and overrides it; the library is republished with a change, the other
|
||||
//! file's app notices in the background and accepts the update (the override stays); usage counts;
|
||||
//! view links can't publish; junk library requests are refused and the server keeps serving.
|
||||
|
||||
use figcraft_collab::message::{Access, ClientMsg, LibraryReply, LibraryReq, OpenLibrary, PROTOCOL, ServerMsg, Usage};
|
||||
use figcraft_collab::sim::Rng;
|
||||
use figcraft_collab_net::Invite;
|
||||
use figcraft_collab_net::hub::Backend;
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::store::Store;
|
||||
use crate::test_util::{Raw, TempDir, config, publish, pump, session};
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
match s.execute(cmd, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{cmd} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn id(v: &Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn fill(s: &Session, id: NodeId) -> String {
|
||||
s.doc().unwrap().node(id).unwrap().fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn open(link: &Invite) -> ClientMsg {
|
||||
ClientMsg::OpenLibrary(OpenLibrary { protocol: PROTOCOL, file: link.file.clone(), token: link.token.clone(), client_version: String::new() })
|
||||
}
|
||||
|
||||
fn answer(raw: &mut Raw) -> LibraryReply {
|
||||
match raw.next() {
|
||||
ServerMsg::Library(r) => *r,
|
||||
other => panic!("not a library answer: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_enable_update_and_usage() {
|
||||
let dir = TempDir::new("library");
|
||||
let cfg = config(dir.path());
|
||||
let mut server = crate::start(&cfg).unwrap();
|
||||
let addr = server.addr();
|
||||
// Ada's library file, kept on the server: a "Button" with a "Label".
|
||||
let mut a = session("Ada");
|
||||
let f = id(&ex(&mut a, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 40, "name": "Button"})));
|
||||
ex(&mut a, "text.create", json!({"text": "OK", "x": 10, "y": 10, "parent": f, "props": {"name": "Label"}}));
|
||||
ex(&mut a, "node.set", json!({"id": f, "props": {"fill": "#0000ff"}}));
|
||||
let button = id(&ex(&mut a, "component.create", json!({"ids": [f]})));
|
||||
let p = publish(&mut a, dir.path(), addr);
|
||||
let mut b = session("Bo");
|
||||
let e = b.execute("library.add", &json!({"path": p.view})).unwrap_err().to_string();
|
||||
assert!(e.contains("no library on the server"), "{e}");
|
||||
let r = ex(&mut a, "library.publishToServer", json!({}));
|
||||
assert_eq!(r["version"], 1);
|
||||
|
||||
// Bo enables it with the view link and places the Button, overriding its label.
|
||||
let r = ex(&mut b, "library.add", json!({"path": p.view}));
|
||||
let lid = r["id"].as_str().unwrap().to_string();
|
||||
assert_eq!(r["components"].as_array().map(Vec::len), Some(1));
|
||||
let inst = id(&ex(&mut b, "library.place", json!({"library": lid, "component": "Button", "x": 0, "y": 0})));
|
||||
let label = b.doc().unwrap().node(inst).unwrap().children[0];
|
||||
ex(&mut b, "text.set", json!({"id": label, "characters": "Buy"}));
|
||||
assert_eq!(fill(&b, inst), "0000FF");
|
||||
assert_eq!(ex(&mut b, "library.list", json!({}))["libraries"][0]["server"], true);
|
||||
|
||||
// Ada changes the Button and publishes again; nothing more to publish after that.
|
||||
ex(&mut a, "node.set", json!({"id": button, "props": {"fill": "#00ff00"}}));
|
||||
assert_eq!(ex(&mut a, "library.publishToServer", json!({}))["version"], 2);
|
||||
let e = a.execute("library.publishToServer", &json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("no changes"), "{e}");
|
||||
|
||||
// Bo's app notices in the background ("Library updates available") and takes the update.
|
||||
b.refresh_server_libraries();
|
||||
pump(&mut [&mut b], "the new version", |s| s[0].libraries.cached(&p.view).is_some_and(|l| l.is_ok_and(|l| l.manifest.version == 2)));
|
||||
assert_eq!(ex(&mut b, "library.updates", json!({}))["count"], 1);
|
||||
assert_eq!(ex(&mut b, "library.acceptUpdates", json!({}))["components"], 1);
|
||||
assert_eq!(fill(&b, inst), "00FF00", "the change arrived");
|
||||
let label = b.doc().unwrap().node(inst).unwrap().children[0];
|
||||
assert_eq!(b.doc().unwrap().node(label).unwrap().kind.text().map(|t| t.characters.as_str()), Some("Buy"), "the override stayed");
|
||||
|
||||
// Usage: what Bo's app reported, seen by Ada (her file's library) and by Bo (by the library).
|
||||
for (s, args) in [(&mut a, json!({})), (&mut b, json!({"library": lid}))] {
|
||||
let u = ex(s, "library.usage", args);
|
||||
assert_eq!(u["instances"], 1, "{u}");
|
||||
assert_eq!(u["files"].as_array().map(Vec::len), Some(1), "{u}");
|
||||
assert_eq!(u["components"][0]["name"], "Button", "{u}");
|
||||
assert_eq!(u["components"][0]["files"], 1, "{u}");
|
||||
}
|
||||
|
||||
// A view link can't publish, whatever the app does.
|
||||
let mine = id(&ex(&mut b, "frame.create", json!({"x": 300, "y": 0, "width": 10, "height": 10, "name": "Mine"})));
|
||||
ex(&mut b, "component.create", json!({"ids": [mine]}));
|
||||
let e = b.execute("library.publishToServer", &json!({"server": p.view})).unwrap_err().to_string();
|
||||
assert!(e.contains("only view"), "{e}");
|
||||
let view = Invite::parse(&p.view).unwrap();
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&open(&view));
|
||||
assert_eq!(answer(&mut raw), LibraryReply::Ready { access: Access::View });
|
||||
raw.send(&ClientMsg::Library(LibraryReq::Publish { archive: vec![1, 2, 3] }));
|
||||
assert!(matches!(answer(&mut raw), LibraryReply::Failed { reason } if reason.contains("only view")));
|
||||
|
||||
// Junk from an edit link: refused, the library unharmed. A wrong token isn't let in.
|
||||
let edit = Invite::parse(&p.edit).unwrap();
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&open(&edit));
|
||||
assert_eq!(answer(&mut raw), LibraryReply::Ready { access: Access::Edit });
|
||||
raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: None, usage: None }));
|
||||
let LibraryReply::Library { version: 2, archive } = answer(&mut raw) else { panic!("no library") };
|
||||
let mut rng = Rng::new(5);
|
||||
for round in 0..12 {
|
||||
let mut junk = if round % 3 == 0 { vec![0x5A; 1 + rng.below(4096)] } else { archive.clone() };
|
||||
for _ in 0..1 + rng.below(8) {
|
||||
let i = rng.below(junk.len());
|
||||
junk[i] = rng.next_u64() as u8;
|
||||
}
|
||||
raw.send(&ClientMsg::Library(LibraryReq::Publish { archive: junk }));
|
||||
assert!(matches!(answer(&mut raw), LibraryReply::Failed { .. }), "round {round}");
|
||||
}
|
||||
let flood = Usage { file: "\u{7}Spam\n".repeat(100), components: (0..5_000).map(|i| (NodeId(i), 1)).collect() };
|
||||
raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: Some(2), usage: Some(flood) }));
|
||||
assert_eq!(answer(&mut raw), LibraryReply::Unchanged { version: 2 });
|
||||
raw.send(&ClientMsg::Library(LibraryReq::Usage));
|
||||
let LibraryReply::Usage(files) = answer(&mut raw) else { panic!("no usage") };
|
||||
let spam = files.iter().find(|f| f.usage.file.starts_with("Spam")).unwrap();
|
||||
assert!(spam.usage.file.chars().count() <= 256 && !spam.usage.file.contains('\n'), "names are cleaned and capped");
|
||||
assert_eq!(spam.usage.components.len(), 2_000, "reports are capped");
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&open(&Invite { token: "nope".into(), ..edit.clone() }));
|
||||
assert!(matches!(raw.next(), ServerMsg::Refused { .. }));
|
||||
assert_eq!(ex(&mut b, "library.list", json!({}))["libraries"][0]["version"], 2);
|
||||
|
||||
// Kept on disk: the library is there after the server stops.
|
||||
server.stop();
|
||||
drop(server);
|
||||
let mut store = Store::open(dir.path(), cfg.store()).unwrap();
|
||||
assert!(matches!(
|
||||
store.library(&edit.file, Access::View, LibraryReq::Fetch { have: None, usage: None }),
|
||||
LibraryReply::Library { version: 2, .. }
|
||||
));
|
||||
}
|
||||
@@ -4,8 +4,7 @@
|
||||
//! that skips the check), the server restarts mid-session and everyone resumes without losing a
|
||||
//! confirmed edit, revoked links stop working at once, and fuzzed frames don't hurt it.
|
||||
|
||||
use std::net::{SocketAddr, TcpStream};
|
||||
use std::path::Path;
|
||||
use std::net::TcpStream;
|
||||
use std::time::Duration;
|
||||
|
||||
use figcraft_collab::message::{self, Access, Admin, AdminReply, ClientMsg, ServerMsg};
|
||||
@@ -13,63 +12,16 @@ use figcraft_collab::sim::Rng;
|
||||
use figcraft_collab::{Client, VIEW_ONLY};
|
||||
use figcraft_collab_net::{Endpoint, Invite, NetLimits, manage};
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_engine::live::{Published, Status};
|
||||
use figcraft_engine::live::Status;
|
||||
use serde_json::{Value, json};
|
||||
use tungstenite::{Bytes, Message, WebSocket};
|
||||
use tungstenite::{Bytes, Message};
|
||||
|
||||
use crate::test_util::{TempDir, config, pump, same, session};
|
||||
|
||||
fn key(dir: &Path) -> String {
|
||||
std::fs::read_to_string(dir.join(crate::store::KEY)).unwrap().trim().to_string()
|
||||
}
|
||||
use crate::test_util::{Raw, TempDir, config, join, key, live, publish, pump, same, session};
|
||||
|
||||
fn id_of(v: &Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn live(s: &Session) -> bool {
|
||||
s.live.status == Status::Live && s.live.syncing()
|
||||
}
|
||||
|
||||
/// `a` publishes its open file to the server at `addr`; the links.
|
||||
fn publish(a: &mut Session, dir: &Path, addr: SocketAddr) -> Published {
|
||||
let r = a.execute("collab.publish", &json!({"server": format!("ws://{addr}/?key={}", key(dir)), "name": "Team file"})).unwrap();
|
||||
assert_eq!(r["status"], "connecting");
|
||||
assert_eq!(r["readOnly"], true, "read-only while it is on its way");
|
||||
pump(&mut [a], "the publish", |s| live(s[0]) && s[0].live.published.is_some());
|
||||
a.live.published.clone().unwrap()
|
||||
}
|
||||
|
||||
fn join(link: &str, name: &str) -> Session {
|
||||
let mut s = session(name);
|
||||
s.execute("collab.join", &json!({"link": link})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// A bare client: hello, then what it's told.
|
||||
struct Raw(WebSocket<TcpStream>);
|
||||
|
||||
impl Raw {
|
||||
fn connect(addr: SocketAddr) -> Raw {
|
||||
let s = TcpStream::connect(addr).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
|
||||
Raw(tungstenite::client::client(format!("ws://{addr}/"), s).unwrap().0)
|
||||
}
|
||||
|
||||
fn send(&mut self, m: &ClientMsg) {
|
||||
self.0.send(Message::Binary(Bytes::from(message::encode(m).unwrap()))).unwrap();
|
||||
}
|
||||
|
||||
fn next(&mut self) -> ServerMsg {
|
||||
loop {
|
||||
if let Message::Binary(b) = self.0.read().unwrap() {
|
||||
return message::decode(&b, usize::MAX).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_then_edit_and_view_together() {
|
||||
let dir = TempDir::new("share");
|
||||
@@ -238,6 +190,16 @@ fn fuzzed_frames_dont_hurt_the_server() {
|
||||
Client::hello(&edit.file, &edit.token, "Fuzz", ""),
|
||||
ClientMsg::Manage(message::Manage { protocol: message::PROTOCOL, key: key(dir.path()), client_version: String::new() }),
|
||||
ClientMsg::Admin(Admin::Links { file: "0000000000000000".into() }),
|
||||
ClientMsg::OpenLibrary(message::OpenLibrary {
|
||||
protocol: message::PROTOCOL,
|
||||
file: edit.file.clone(),
|
||||
token: edit.token.clone(),
|
||||
client_version: String::new(),
|
||||
}),
|
||||
ClientMsg::Library(message::LibraryReq::Fetch {
|
||||
have: None,
|
||||
usage: Some(message::Usage { file: "Fuzz".into(), components: vec![(NodeId(1), 2)] }),
|
||||
}),
|
||||
ClientMsg::Ping,
|
||||
]
|
||||
.iter()
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
//! <data>/files/<id>/links.json share links: id, access, label, created, SHA-256 of the token
|
||||
//! <data>/files/<id>/snapshot.bin the session state at the last compaction
|
||||
//! <data>/files/<id>/log.bin what changed since (see `disk`)
|
||||
//! <data>/files/<id>/library.bin its team library, when one was published (a `.figcraft` archive)
|
||||
//! <data>/files/<id>/usage.json how other files use that library, as their apps report it
|
||||
//! ```
|
||||
//!
|
||||
//! An open file appends its server's journal to its log; the hub has the store make the appends
|
||||
@@ -20,23 +22,30 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::{self, File, OpenOptions, TryLockError};
|
||||
use std::io::Write;
|
||||
use std::io::{Cursor, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use figcraft_collab::message::{Access, Admin, AdminReply, FileInfo, LinkInfo, NewLink};
|
||||
use figcraft_collab::message::{Access, Admin, AdminReply, FileInfo, FileUsage, LibraryReply, LibraryReq, LinkInfo, NewLink, Usage};
|
||||
use figcraft_collab::{Change, Replica, Server, same_secret};
|
||||
use figcraft_collab_net::hub::{Admission, Backend, Closing, Handled, OpenFile};
|
||||
use figcraft_collab_net::token;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::disk::{self, LINKS, LOG, LOG_HEADER, META, SNAPSHOT};
|
||||
use crate::disk::{self, LIBRARY, LINKS, LOG, LOG_HEADER, META, SNAPSHOT, USAGE};
|
||||
|
||||
pub const LOCK: &str = "LOCK";
|
||||
pub const KEY: &str = "server.key";
|
||||
pub const FILES: &str = "files";
|
||||
|
||||
/// Most files whose usage of one library is kept (the least recently changed go first), and most
|
||||
/// components one file's report keeps.
|
||||
const MAX_USERS: usize = 500;
|
||||
const MAX_REPORTED: usize = 2_000;
|
||||
/// Largest `usage.json` read.
|
||||
const MAX_USAGE_FILE: u64 = 32 << 20;
|
||||
|
||||
/// What the store enforces.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct StoreConfig {
|
||||
@@ -56,6 +65,17 @@ struct Meta {
|
||||
updated: u64,
|
||||
#[serde(default)]
|
||||
seq: u64,
|
||||
/// The team library published under the file, if any.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
library: Option<LibraryMeta>,
|
||||
}
|
||||
|
||||
/// What a file's team library is (`library.bin` holds it).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
struct LibraryMeta {
|
||||
id: String,
|
||||
name: String,
|
||||
version: u64,
|
||||
}
|
||||
|
||||
/// A share link (`links.json`): the token itself is never stored.
|
||||
@@ -313,7 +333,7 @@ impl Store {
|
||||
let t = now();
|
||||
let (edit, edit_token) = new_link(Access::Edit, "Published (edit)", t)?;
|
||||
let (view, view_token) = new_link(Access::View, "Published (view)", t)?;
|
||||
let meta = Meta { name, created: t, updated: t, seq: 0 };
|
||||
let meta = Meta { name, created: t, updated: t, seq: 0, library: None };
|
||||
let dir = self.dir(&id);
|
||||
let written = fs::create_dir_all(&dir)
|
||||
.map_err(|e| e.to_string())
|
||||
@@ -387,6 +407,92 @@ impl Store {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A file's team library, unless its version is `have`.
|
||||
fn fetch_library(&mut self, file: &str, have: Option<u64>) -> Result<LibraryReply, String> {
|
||||
let (path, max) = (self.dir(file).join(LIBRARY), self.cfg.max_file_bytes);
|
||||
let Some(lib) = self.entry(file)?.meta.library.clone() else { return Ok(LibraryReply::Empty) };
|
||||
if have == Some(lib.version) {
|
||||
return Ok(LibraryReply::Unchanged { version: lib.version });
|
||||
}
|
||||
Ok(LibraryReply::Library { version: lib.version, archive: disk::read_capped(&path, max)? })
|
||||
}
|
||||
|
||||
/// Keep `archive` as a file's team library: it must read as a library (with the limits files
|
||||
/// using it read it with), be the file's library if it has one, and be a newer version.
|
||||
// shortcut: the archive is checked on the hub thread (other files' sessions wait meanwhile);
|
||||
// move it to a worker if large libraries make that noticeable.
|
||||
pub fn publish_library(&mut self, file: &str, archive: &[u8]) -> Result<u64, String> {
|
||||
let max = self.cfg.max_file_bytes;
|
||||
if archive.len() as u64 > max {
|
||||
return Err(format!("the library is {} MiB; this server keeps files up to {} MiB", archive.len() >> 20, max >> 20));
|
||||
}
|
||||
let lib = match figcraft_format::read_library(Cursor::new(archive)) {
|
||||
Ok(Some(lib)) => lib,
|
||||
Ok(None) => return Err("that isn't a published library".into()),
|
||||
Err(e) => return Err(format!("the library can't be read: {e}")),
|
||||
};
|
||||
if lib.doc.node_count() > self.cfg.core.max_nodes {
|
||||
return Err(format!("the library has more than {} layers", self.cfg.core.max_nodes));
|
||||
}
|
||||
let m = &lib.manifest;
|
||||
let dir = self.dir(file);
|
||||
let entry = self.entry(file)?;
|
||||
if let Some(old) = &entry.meta.library {
|
||||
if old.id != m.id {
|
||||
return Err(format!("this file's library on the server is another one (\"{}\")", old.name));
|
||||
}
|
||||
if m.version <= old.version {
|
||||
return Err(format!("version {} is on the server already: publish again to follow it", old.version));
|
||||
}
|
||||
}
|
||||
// The archive first: a description never names a version whose library isn't there (a
|
||||
// failed description write leaves the older number, which the next publish follows).
|
||||
disk::write_atomic(&dir.join(LIBRARY), archive).map_err(|e| format!("couldn't keep the library: {e}"))?;
|
||||
let before = entry.meta.library.replace(LibraryMeta { id: m.id.clone(), name: m.name.clone(), version: m.version });
|
||||
if let Err(e) = write_json(&dir.join(META), &entry.meta) {
|
||||
entry.meta.library = before;
|
||||
return Err(format!("couldn't keep the library: {e}"));
|
||||
}
|
||||
log::info!("file {file}: published library version {} ({} bytes)", m.version, archive.len());
|
||||
Ok(m.version)
|
||||
}
|
||||
|
||||
/// The usage reports kept for a file's library (none when it has no library).
|
||||
pub fn library_usage(&mut self, file: &str) -> Result<Vec<FileUsage>, String> {
|
||||
let path = self.dir(file).join(USAGE);
|
||||
if self.entry(file)?.meta.library.is_none() || !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
serde_json::from_slice(&disk::read_capped(&path, MAX_USAGE_FILE)?).map_err(|e| format!("{USAGE} is damaged ({e})"))
|
||||
}
|
||||
|
||||
/// Keep how one file uses a file's library (written only when it changed).
|
||||
pub fn report_usage(&mut self, file: &str, usage: Usage) -> Result<(), String> {
|
||||
let name = clean_name(&usage.file);
|
||||
if name.is_empty() || self.entry(file)?.meta.library.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut components: Vec<_> = usage.components.into_iter().filter(|(_, n)| *n > 0).collect();
|
||||
components.sort();
|
||||
components.dedup_by_key(|c| c.0);
|
||||
components.truncate(MAX_REPORTED);
|
||||
// A damaged report list starts over (it is only statistics).
|
||||
let mut all = self.library_usage(file).unwrap_or_default();
|
||||
match all.iter_mut().find(|f| f.usage.file == name) {
|
||||
Some(f) if f.usage.components == components => return Ok(()),
|
||||
Some(f) => {
|
||||
f.usage.components = components;
|
||||
f.updated = now();
|
||||
}
|
||||
None => all.push(FileUsage { usage: Usage { file: name, components }, updated: now() }),
|
||||
}
|
||||
if all.len() > MAX_USERS {
|
||||
all.sort_by_key(|f| std::cmp::Reverse(f.updated));
|
||||
all.truncate(MAX_USERS);
|
||||
}
|
||||
write_json(&self.dir(file).join(USAGE), &all)
|
||||
}
|
||||
|
||||
/// Load a file's session: its snapshot, then its log (a damaged end is dropped and reported).
|
||||
fn load(&mut self, file: &str) -> Result<Server, String> {
|
||||
let dir = self.dir(file);
|
||||
@@ -609,6 +715,27 @@ impl Backend for Store {
|
||||
}
|
||||
}
|
||||
|
||||
fn library(&mut self, file: &str, access: Access, req: LibraryReq) -> LibraryReply {
|
||||
let failed = |reason: String| LibraryReply::Failed { reason };
|
||||
match req {
|
||||
LibraryReq::Fetch { have, usage } => {
|
||||
if let Some(u) = usage
|
||||
&& let Err(e) = self.report_usage(file, u)
|
||||
{
|
||||
log::warn!("file {file}: a library usage report couldn't be kept: {e}");
|
||||
}
|
||||
self.fetch_library(file, have).unwrap_or_else(failed)
|
||||
}
|
||||
LibraryReq::Publish { .. } if access != Access::Edit => {
|
||||
failed("you can only view this file: publishing its library needs an edit link".into())
|
||||
}
|
||||
LibraryReq::Publish { archive } => {
|
||||
self.publish_library(file, &archive).map_or_else(failed, |version| LibraryReply::Published { version })
|
||||
}
|
||||
LibraryReq::Usage => self.library_usage(file).map_or_else(failed, LibraryReply::Usage),
|
||||
}
|
||||
}
|
||||
|
||||
fn closing(&self) -> Closing {
|
||||
Closing::Restarting
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
//! What the tests share: temporary data directories (removed when dropped), settings, and engine
|
||||
//! sessions linked over loopback.
|
||||
|
||||
use std::net::{SocketAddr, TcpStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use figcraft_collab::message::{self, ClientMsg, ServerMsg};
|
||||
use figcraft_collab_net::NetLink;
|
||||
use figcraft_collab_net::guest::GuestConfig;
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_engine::live::{Published, Status};
|
||||
use serde_json::json;
|
||||
use tungstenite::{Bytes, Message, WebSocket};
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
@@ -81,3 +85,49 @@ pub fn pump(sessions: &mut [&mut Session], what: &str, f: impl Fn(&[&mut Session
|
||||
pub fn same(a: &figcraft_doc::Document, b: &figcraft_doc::Document) -> bool {
|
||||
figcraft_collab::diff(&figcraft_collab::Replica::new(a.clone()), b).is_ok_and(|d| d.changes.is_empty())
|
||||
}
|
||||
|
||||
pub fn key(dir: &Path) -> String {
|
||||
std::fs::read_to_string(dir.join(crate::store::KEY)).unwrap().trim().to_string()
|
||||
}
|
||||
|
||||
pub fn live(s: &Session) -> bool {
|
||||
s.live.status == Status::Live && s.live.syncing()
|
||||
}
|
||||
|
||||
/// `a` publishes its open file to the server at `addr`; the links.
|
||||
pub fn publish(a: &mut Session, dir: &Path, addr: SocketAddr) -> Published {
|
||||
let r = a.execute("collab.publish", &json!({"server": format!("ws://{addr}/?key={}", key(dir)), "name": "Team file"})).unwrap();
|
||||
assert_eq!(r["status"], "connecting");
|
||||
assert_eq!(r["readOnly"], true, "read-only while it is on its way");
|
||||
pump(&mut [a], "the publish", |s| live(s[0]) && s[0].live.published.is_some());
|
||||
a.live.published.clone().unwrap()
|
||||
}
|
||||
|
||||
pub fn join(link: &str, name: &str) -> Session {
|
||||
let mut s = session(name);
|
||||
s.execute("collab.join", &json!({"link": link})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// A bare client: hello, then what it's told.
|
||||
pub struct Raw(WebSocket<TcpStream>);
|
||||
|
||||
impl Raw {
|
||||
pub fn connect(addr: SocketAddr) -> Raw {
|
||||
let s = TcpStream::connect(addr).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
|
||||
Raw(tungstenite::client::client(format!("ws://{addr}/"), s).unwrap().0)
|
||||
}
|
||||
|
||||
pub fn send(&mut self, m: &ClientMsg) {
|
||||
self.0.send(Message::Binary(Bytes::from(message::encode(m).unwrap()))).unwrap();
|
||||
}
|
||||
|
||||
pub fn next(&mut self) -> ServerMsg {
|
||||
loop {
|
||||
if let Message::Binary(b) = self.0.read().unwrap() {
|
||||
return message::decode(&b, usize::MAX).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use std::net::TcpStream;
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, TryRecvError, channel};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::Instant;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use figcraft_collab::Replica;
|
||||
use figcraft_collab::message::{self, Admin, AdminReply, ClientMsg, Presence, ServerMsg};
|
||||
@@ -28,7 +28,7 @@ use crate::invite::{Endpoint, Invite};
|
||||
pub use crate::join::GuestConfig;
|
||||
use crate::join::{Next, Outcome, Resuming, Seen, Taken, after, closed, hello, take};
|
||||
use crate::manage;
|
||||
use crate::net::{Duplex, config, dial, shutdown};
|
||||
use crate::net::{Duplex, NetLimits, config, dial, shutdown};
|
||||
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
|
||||
|
||||
/// What [`Guest::publish`] uploads, and where.
|
||||
@@ -138,19 +138,31 @@ struct Conn {
|
||||
sock: TcpStream,
|
||||
}
|
||||
|
||||
/// Upload the document; the join settings for the new file and its links.
|
||||
fn upload(job: &Publish, mut cfg: GuestConfig) -> Result<(GuestConfig, Published), String> {
|
||||
let snapshot = Replica::new((*job.doc).clone()).snapshot().map_err(|e| e.to_string())?;
|
||||
let replies =
|
||||
manage::request(&job.server, &job.key, vec![Admin::Publish { name: job.name.clone(), snapshot }], &cfg.limits, cfg.connect_timeout)?;
|
||||
/// Keep `doc` on `server` as a new file named `name`: its edit link, and where it is with its links.
|
||||
pub(crate) fn upload_file(
|
||||
server: &Endpoint,
|
||||
key: &str,
|
||||
name: &str,
|
||||
doc: &Document,
|
||||
limits: &NetLimits,
|
||||
timeout: Duration,
|
||||
) -> Result<(Invite, Published), String> {
|
||||
let snapshot = Replica::new(doc.clone()).snapshot().map_err(|e| e.to_string())?;
|
||||
let replies = manage::request(server, key, vec![Admin::Publish { name: name.to_string(), snapshot }], limits, timeout)?;
|
||||
let (file, edit, view) = match replies.into_iter().next() {
|
||||
Some(AdminReply::Published { file, edit, view }) => (file, edit, view),
|
||||
Some(AdminReply::Failed { reason }) => return Err(reason),
|
||||
_ => return Err("the server didn't say where the file is".into()),
|
||||
};
|
||||
let link = |token: String| Invite { server: job.server.clone(), file: file.id.clone(), token };
|
||||
let link = |token: String| Invite { server: server.clone(), file: file.id.clone(), token };
|
||||
let (edit, view) = (link(edit.token), link(view.token));
|
||||
let published = Published { server: job.server.display(), file: file.id.clone(), name: file.name, edit: edit.link(), view: view.link() };
|
||||
let published = Published { server: server.display(), file: file.id.clone(), name: file.name, edit: edit.link(), view: view.link() };
|
||||
Ok((edit, published))
|
||||
}
|
||||
|
||||
/// Upload the document; the join settings for the new file and its links.
|
||||
fn upload(job: &Publish, mut cfg: GuestConfig) -> Result<(GuestConfig, Published), String> {
|
||||
let (edit, published) = upload_file(&job.server, &job.key, &job.name, &job.doc, &cfg.limits, cfg.connect_timeout)?;
|
||||
cfg.invite = edit;
|
||||
Ok((cfg, published))
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
//!
|
||||
//! What differs between the two lives behind a [`Backend`]: which hellos it admits and with what
|
||||
//! access, where a file that isn't open comes from, what is kept of the changes (a server appends
|
||||
//! them to its log; the app keeps nothing), and the managing connections a server answers. The
|
||||
//! person hosting an app session takes part through the hub directly (no socket): their presence
|
||||
//! and edits go in with [`Hosting::presence`] and [`Hosting::send`], what others do comes out
|
||||
//! through their [`Inbox`].
|
||||
//! them to its log; the app keeps nothing), and the managing and team library connections a
|
||||
//! server answers. The person hosting an app session takes part through the hub directly (no
|
||||
//! socket): their presence and edits go in with [`Hosting::presence`] and [`Hosting::send`], what
|
||||
//! others do comes out through their [`Inbox`].
|
||||
//!
|
||||
//! On top of the core the hub hands out colours and default names per file, throttles presence to
|
||||
//! about 30 updates a second per person (latest wins), tells a newcomer its own name and colour (a
|
||||
@@ -25,7 +25,9 @@ use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, SyncSender, TrySendErr
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use figcraft_collab::message::{self, Access, Admin, AdminReply, ClientMsg, Hello, Manage, PROTOCOL, Presence, ServerMsg};
|
||||
use figcraft_collab::message::{
|
||||
self, Access, Admin, AdminReply, ClientMsg, Hello, LibraryReply, LibraryReq, Manage, OpenLibrary, PROTOCOL, Presence, ServerMsg,
|
||||
};
|
||||
use figcraft_collab::{Outgoing, Server};
|
||||
use figcraft_engine::live::{Event, MAX_NAME, PEOPLE, sanitize};
|
||||
use tungstenite::protocol::frame::coding::CloseCode;
|
||||
@@ -122,6 +124,12 @@ pub trait Backend: Send + 'static {
|
||||
AdminReply::Failed { reason: "nothing to manage here".into() }.into()
|
||||
}
|
||||
|
||||
/// A request on a library connection to `file`, opened with a link of `access` (checked by
|
||||
/// [`admit`](Self::admit)).
|
||||
fn library(&mut self, _file: &str, _access: Access, _req: LibraryReq) -> LibraryReply {
|
||||
LibraryReply::Failed { reason: "this session is hosted by an app: team libraries live on a figcraft-server".into() }
|
||||
}
|
||||
|
||||
/// What everyone is told when the hub stops.
|
||||
fn closing(&self) -> Closing;
|
||||
}
|
||||
@@ -133,7 +141,7 @@ enum Out {
|
||||
}
|
||||
|
||||
enum HubIn {
|
||||
/// A connection's first message (`Hello` or `Manage`).
|
||||
/// A connection's first message (`Hello`, `Manage` or `OpenLibrary`).
|
||||
Open {
|
||||
conn: u64,
|
||||
first: ClientMsg,
|
||||
@@ -396,7 +404,7 @@ fn serve(stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimits, _
|
||||
}
|
||||
};
|
||||
match next_message(&mut ws, limits.max_hello) {
|
||||
Ok(Some(first @ (ClientMsg::Hello(_) | ClientMsg::Manage(_)))) => {
|
||||
Ok(Some(first @ (ClientMsg::Hello(_) | ClientMsg::Manage(_) | ClientMsg::OpenLibrary(_)))) => {
|
||||
ws.set_config(|c| *c = cfg);
|
||||
let opened = sock.try_clone().is_ok_and(|s| hub.send(HubIn::Open { conn, first, out: out.clone(), sock: s }).is_ok());
|
||||
if opened && ws.get_ref().set_read_timeout(Some(limits.idle_timeout)).is_ok() {
|
||||
@@ -486,6 +494,8 @@ enum Kind {
|
||||
Joined { file: String, id: u32, link: Option<String> },
|
||||
/// Managing the server's files.
|
||||
Managing,
|
||||
/// Using `file`'s team library through a link of `access`.
|
||||
Library { file: String, access: Access },
|
||||
}
|
||||
|
||||
/// A joined or managing connection.
|
||||
@@ -576,6 +586,7 @@ impl<B: Backend> Hub<B> {
|
||||
match m {
|
||||
HubIn::Open { conn, first: ClientMsg::Hello(hello), out, sock } => self.join(conn, hello, out, sock),
|
||||
HubIn::Open { conn, first: ClientMsg::Manage(m), out, sock } => self.manage(conn, &m, out, sock),
|
||||
HubIn::Open { conn, first: ClientMsg::OpenLibrary(o), out, sock } => self.open_library(conn, o, out, sock),
|
||||
HubIn::Open { out, .. } => refuse(&out, "say hello first"),
|
||||
HubIn::Msg { conn, msg } => match self.conns.get(&conn).map(|c| &c.kind) {
|
||||
Some(Kind::Joined { file, id, .. }) => {
|
||||
@@ -587,6 +598,15 @@ impl<B: Backend> Hub<B> {
|
||||
ClientMsg::Ping => self.answer(conn, &ServerMsg::Pong),
|
||||
_ => self.kick(&[conn], "only requests to manage files are spoken on this connection"),
|
||||
},
|
||||
Some(Kind::Library { file, access }) => match msg {
|
||||
ClientMsg::Library(req) => {
|
||||
let (file, access) = (file.clone(), *access);
|
||||
let reply = self.backend.library(&file, access, req);
|
||||
self.answer(conn, &ServerMsg::Library(Box::new(reply)));
|
||||
}
|
||||
ClientMsg::Ping => self.answer(conn, &ServerMsg::Pong),
|
||||
_ => self.kick(&[conn], "only library requests are spoken on this connection"),
|
||||
},
|
||||
None => {}
|
||||
},
|
||||
HubIn::Gone { conn } => self.gone(conn),
|
||||
@@ -701,6 +721,18 @@ impl<B: Backend> Hub<B> {
|
||||
self.answer(conn, &ServerMsg::Admin(Box::new(AdminReply::Ready)));
|
||||
}
|
||||
|
||||
fn open_library(&mut self, conn: u64, o: OpenLibrary, out: SyncSender<Out>, sock: TcpStream) {
|
||||
if o.protocol != PROTOCOL {
|
||||
return refuse(&out, &format!("protocol {} isn't supported (this server speaks {PROTOCOL})", o.protocol));
|
||||
}
|
||||
let access = match self.backend.admit(&o.file, &o.token) {
|
||||
Ok(a) => a.access,
|
||||
Err(why) => return refuse(&out, &why),
|
||||
};
|
||||
self.conns.insert(conn, Conn { out, sock, kind: Kind::Library { file: o.file, access } });
|
||||
self.answer(conn, &ServerMsg::Library(Box::new(LibraryReply::Ready { access })));
|
||||
}
|
||||
|
||||
fn admin(&mut self, conn: u64, req: Admin) {
|
||||
let rooms = &self.rooms;
|
||||
let open = |file: &str| {
|
||||
|
||||
@@ -207,8 +207,8 @@ pub(crate) fn take(m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mut Se
|
||||
inbox.push(Event::Sync(m));
|
||||
}
|
||||
ServerMsg::Reject { .. } => inbox.push(Event::Sync(m)),
|
||||
// Answers for managing connections: not this one's.
|
||||
ServerMsg::Pong | ServerMsg::Admin(_) => {}
|
||||
// Answers for managing and library connections: not this one's.
|
||||
ServerMsg::Pong | ServerMsg::Admin(_) | ServerMsg::Library(_) => {}
|
||||
}
|
||||
Taken::Continue
|
||||
}
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
//! [`NetLink`]: the engine's [`LiveLink`] over the network.
|
||||
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use figcraft_collab::message::{Access, ClientMsg};
|
||||
use figcraft_engine::live::{Bind, Event, HostOptions, LiveLink, PageInfo, Presence, PublishOptions, Role, SessionInfo};
|
||||
use figcraft_collab::message::{Access, ClientMsg, LibraryReply, LibraryReq};
|
||||
use figcraft_engine::live::{Bind, Event, HostOptions, LiveLink, PageInfo, Presence, PublishOptions, Published, Role, SessionInfo};
|
||||
|
||||
use crate::guest::{Guest, GuestConfig, Publish};
|
||||
use crate::guest::{Guest, GuestConfig, Publish, upload_file};
|
||||
use crate::host::{Host, HostConfig};
|
||||
use crate::inbox::{Inbox, Waker};
|
||||
use crate::invite::{Endpoint, Invite};
|
||||
use crate::manage::NO_TLS;
|
||||
use crate::manage::{self, NO_TLS};
|
||||
use crate::net::NetLimits;
|
||||
use crate::token;
|
||||
|
||||
/// The port a hosted session tries first.
|
||||
pub const DEFAULT_PORT: u16 = 47900;
|
||||
|
||||
/// How long a request to a server (outside a session) waits for its connection.
|
||||
const CONNECT: Duration = Duration::from_secs(5);
|
||||
|
||||
enum Active {
|
||||
Host(Host),
|
||||
Guest(Guest),
|
||||
@@ -29,12 +35,17 @@ pub struct NetLink {
|
||||
inbox: Option<Inbox>,
|
||||
/// Tweaks for joining (tests shorten the reconnect pauses).
|
||||
guest: fn(GuestConfig) -> GuestConfig,
|
||||
/// Answers to library requests sent in the background (outlives sessions).
|
||||
libraries: Inbox,
|
||||
/// Background library requests are out.
|
||||
fetching: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl NetLink {
|
||||
/// `wake` is called (from network threads) whenever something arrives for the UI.
|
||||
pub fn new(wake: Waker) -> Self {
|
||||
Self { wake, limits: NetLimits::default(), active: None, inbox: None, guest: |c| c }
|
||||
let libraries = Inbox::new(wake.clone());
|
||||
Self { wake, limits: NetLimits::default(), active: None, inbox: None, guest: |c| c, libraries, fetching: Arc::default() }
|
||||
}
|
||||
|
||||
/// With other transport limits.
|
||||
@@ -157,6 +168,35 @@ impl LiveLink for NetLink {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn upload(&mut self, o: PublishOptions) -> Result<Published, String> {
|
||||
let (server, query) = Endpoint::parse(&o.server)?;
|
||||
let key = o.key.or_else(|| query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v)).filter(|k| !k.trim().is_empty());
|
||||
let key = key.ok_or("the server address needs the server's key (ws://host:port/?key=…; `figcraft-server key` prints it)")?;
|
||||
upload_file(&server, &key, &o.name, &o.doc, &self.limits, CONNECT).map(|(_, published)| published)
|
||||
}
|
||||
|
||||
fn library(&mut self, link: &str, req: LibraryReq) -> Result<LibraryReply, String> {
|
||||
manage::library(link, req, &self.limits, CONNECT)
|
||||
}
|
||||
|
||||
fn library_background(&mut self, jobs: Vec<(String, LibraryReq)>) {
|
||||
if jobs.is_empty() || self.fetching.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let (inbox, fetching, limits) = (self.libraries.clone(), self.fetching.clone(), self.limits);
|
||||
let spawned = std::thread::Builder::new().name("library-fetch".into()).spawn(move || {
|
||||
for (link, req) in jobs {
|
||||
let reply = manage::library(&link, req, &limits, CONNECT);
|
||||
inbox.push(Event::Library { link, reply });
|
||||
}
|
||||
fetching.store(false, Ordering::SeqCst);
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
log::warn!("team libraries: can't start a thread: {e}");
|
||||
self.fetching.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
fn leave(&mut self) {
|
||||
match self.active.take() {
|
||||
Some(Active::Host(mut h)) => h.stop(),
|
||||
@@ -183,7 +223,9 @@ impl LiveLink for NetLink {
|
||||
}
|
||||
|
||||
fn poll(&mut self) -> Vec<Event> {
|
||||
self.inbox.as_ref().map(Inbox::drain).unwrap_or_default()
|
||||
let mut out = self.libraries.drain();
|
||||
out.extend(self.inbox.as_ref().map(Inbox::drain).unwrap_or_default());
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,62 +1,101 @@
|
||||
//! Managing a server's files from a client: one connection that opens with the server's key
|
||||
//! ([`Manage`]) and sends requests, each answered in order. The app publishes through it, and the
|
||||
//! server's command line uses it to reach a running server.
|
||||
//! Requests to a server outside a live session, each on a connection of its own answered in
|
||||
//! order: managing its files with the server's key ([`Manage`], [`request`]; the app publishes
|
||||
//! through it, and the server's command line uses it to reach a running server), and using a
|
||||
//! file's team library with one of the file's share links ([`OpenLibrary`], [`library`]).
|
||||
|
||||
use std::net::TcpStream;
|
||||
use std::time::Duration;
|
||||
|
||||
use figcraft_collab::message::{self, Admin, AdminReply, ClientMsg, Manage, PROTOCOL, ServerMsg};
|
||||
use figcraft_collab::message::{self, Admin, AdminReply, ClientMsg, LibraryReply, LibraryReq, Manage, OpenLibrary, PROTOCOL, ServerMsg};
|
||||
use tungstenite::{Bytes, Message, WebSocket};
|
||||
|
||||
use crate::invite::Endpoint;
|
||||
use crate::invite::{Endpoint, Invite};
|
||||
use crate::net::{NetLimits, config, dial};
|
||||
|
||||
/// Why the desktop app can't reach a `wss://` address (it has no TLS of its own yet).
|
||||
pub const NO_TLS: &str =
|
||||
"The desktop app can't open secure (wss://) connections yet: use the server's ws:// address, or join from FigCraft in the browser";
|
||||
|
||||
type Ws = WebSocket<TcpStream>;
|
||||
|
||||
/// Send `requests` to the server at `server` with its `key`; the replies, in order. Fails when the
|
||||
/// server can't be reached, refuses the key or closes the connection (a request it can't do is a
|
||||
/// [`AdminReply::Failed`] reply, not an error). Never echoes the key.
|
||||
pub fn request(server: &Endpoint, key: &str, requests: Vec<Admin>, limits: &NetLimits, timeout: Duration) -> Result<Vec<AdminReply>, String> {
|
||||
if server.secure {
|
||||
return Err(NO_TLS.into());
|
||||
}
|
||||
let stream = dial(&server.host, timeout, limits)?;
|
||||
let (mut ws, _) = tungstenite::client::client_with_config(server.url(), stream, Some(config(limits.max_server_message)))
|
||||
.map_err(|e| format!("{} doesn't answer as a FigCraft server: {e}", server.display()))?;
|
||||
// A large upload takes the server a while to keep.
|
||||
ws.get_ref().set_read_timeout(Some(limits.idle_timeout)).map_err(|e| e.to_string())?;
|
||||
let admin = |m: ServerMsg| match m {
|
||||
ServerMsg::Admin(r) => Some(*r),
|
||||
_ => None,
|
||||
};
|
||||
let manage = Manage { protocol: PROTOCOL, key: key.to_string(), client_version: env!("CARGO_PKG_VERSION").to_string() };
|
||||
let mut ws = connect(server, limits, timeout)?;
|
||||
send(&mut ws, &ClientMsg::Manage(manage))?;
|
||||
match reply(&mut ws, limits)? {
|
||||
match reply(&mut ws, limits, admin)? {
|
||||
AdminReply::Ready => {}
|
||||
other => return Err(format!("the server answered {other:?} instead of letting this connection manage files")),
|
||||
}
|
||||
let mut replies = Vec::with_capacity(requests.len());
|
||||
for r in requests {
|
||||
send(&mut ws, &ClientMsg::Admin(r))?;
|
||||
replies.push(reply(&mut ws, limits)?);
|
||||
replies.push(reply(&mut ws, limits, admin)?);
|
||||
}
|
||||
let _ = ws.close(None);
|
||||
// Read until the server's close arrives (or the socket ends), so the close isn't lost.
|
||||
while ws.read().is_ok() {}
|
||||
close(ws);
|
||||
Ok(replies)
|
||||
}
|
||||
|
||||
fn send(ws: &mut WebSocket<std::net::TcpStream>, msg: &ClientMsg) -> Result<(), String> {
|
||||
/// Send one library request about the file behind share link `link`; the reply. Fails when the
|
||||
/// link isn't one, the server can't be reached or refuses the link (a request it can't do is a
|
||||
/// [`LibraryReply::Failed`] reply). Never echoes the link.
|
||||
pub fn library(link: &str, req: LibraryReq, limits: &NetLimits, timeout: Duration) -> Result<LibraryReply, String> {
|
||||
let lib = |m: ServerMsg| match m {
|
||||
ServerMsg::Library(r) => Some(*r),
|
||||
_ => None,
|
||||
};
|
||||
let invite = Invite::parse(link)?;
|
||||
let open = OpenLibrary { protocol: PROTOCOL, file: invite.file, token: invite.token, client_version: env!("CARGO_PKG_VERSION").to_string() };
|
||||
let mut ws = connect(&invite.server, limits, timeout)?;
|
||||
send(&mut ws, &ClientMsg::OpenLibrary(open))?;
|
||||
match reply(&mut ws, limits, lib)? {
|
||||
LibraryReply::Ready { .. } => {}
|
||||
other => return Err(format!("the server answered {other:?} instead of opening the library")),
|
||||
}
|
||||
send(&mut ws, &ClientMsg::Library(req))?;
|
||||
let r = reply(&mut ws, limits, lib)?;
|
||||
close(ws);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// A WebSocket to `server` that waits for answers up to the idle timeout (a large upload takes the
|
||||
/// server a while to keep).
|
||||
fn connect(server: &Endpoint, limits: &NetLimits, timeout: Duration) -> Result<Ws, String> {
|
||||
if server.secure {
|
||||
return Err(NO_TLS.into());
|
||||
}
|
||||
let stream = dial(&server.host, timeout, limits)?;
|
||||
let (ws, _) = tungstenite::client::client_with_config(server.url(), stream, Some(config(limits.max_server_message)))
|
||||
.map_err(|e| format!("{} doesn't answer as a FigCraft server: {e}", server.display()))?;
|
||||
ws.get_ref().set_read_timeout(Some(limits.idle_timeout)).map_err(|e| e.to_string())?;
|
||||
Ok(ws)
|
||||
}
|
||||
|
||||
/// Close politely: read until the server's close arrives (or the socket ends), so it isn't lost.
|
||||
fn close(mut ws: Ws) {
|
||||
let _ = ws.close(None);
|
||||
while ws.read().is_ok() {}
|
||||
}
|
||||
|
||||
fn send(ws: &mut Ws, msg: &ClientMsg) -> Result<(), String> {
|
||||
let bytes = message::encode(msg).map_err(|e| e.to_string())?;
|
||||
ws.send(Message::Binary(Bytes::from(bytes))).map_err(|e| format!("the connection to the server dropped ({e})"))
|
||||
}
|
||||
|
||||
/// The next answer: an admin reply, or why there is none.
|
||||
fn reply(ws: &mut WebSocket<std::net::TcpStream>, limits: &NetLimits) -> Result<AdminReply, String> {
|
||||
/// The next answer `pick` takes, or why there is none.
|
||||
fn reply<T>(ws: &mut Ws, limits: &NetLimits, pick: impl Fn(ServerMsg) -> Option<T>) -> Result<T, String> {
|
||||
loop {
|
||||
return match ws.read() {
|
||||
Ok(Message::Binary(b)) => match message::decode::<ServerMsg>(&b, limits.max_server_message) {
|
||||
Ok(ServerMsg::Admin(r)) => Ok(*r),
|
||||
Ok(ServerMsg::Refused { reason }) => Err(format!("the server refused: {reason}")),
|
||||
Ok(ServerMsg::Pong) => continue,
|
||||
Ok(_) => Err("the server sent something other than an answer".into()),
|
||||
Ok(m) => pick(m).ok_or_else(|| "the server sent something other than an answer".into()),
|
||||
Err(e) => Err(format!("the server sent a message this version can't read ({e})")),
|
||||
},
|
||||
Ok(Message::Close(f)) => Err(match f.map(|f| f.reason.to_string()).filter(|r| !r.is_empty()) {
|
||||
|
||||
@@ -459,7 +459,7 @@ impl Client {
|
||||
self.rejoin(seq, catch_up, &processed, peers, recompute)
|
||||
}
|
||||
ServerMsg::Refused { reason } => Err(Error::Protocol(reason)),
|
||||
ServerMsg::Admin(_) => Err(Error::Protocol("an answer meant for a managing connection".into())),
|
||||
ServerMsg::Admin(_) | ServerMsg::Library(_) => Err(Error::Protocol("an answer meant for a managing or library connection".into())),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ use figcraft_doc::{Document, NodeId, NodeKind};
|
||||
|
||||
use crate::change::{Change, Json, Prop};
|
||||
use crate::message::{
|
||||
Accepted, Access, Admin, AdminReply, CatchUp, ClientMsg, EditingText, FileInfo, Hello, LinkInfo, Manage, NewLink, PROTOCOL, Peer, Presence,
|
||||
Processed, Resume, ServerMsg, WIRE_VERSION, decode, decode_json, encode, encode_json,
|
||||
Accepted, Access, Admin, AdminReply, CatchUp, ClientMsg, EditingText, FileInfo, FileUsage, Hello, LibraryReply, LibraryReq, LinkInfo, Manage,
|
||||
NewLink, OpenLibrary, PROTOCOL, Peer, Presence, Processed, Resume, ServerMsg, Usage, WIRE_VERSION, decode, decode_json, encode, encode_json,
|
||||
};
|
||||
use crate::sim::Rng;
|
||||
use crate::{Error, Replica, diff};
|
||||
@@ -56,6 +56,10 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
|
||||
ClientMsg::Admin(Admin::CreateLink { file: "f".into(), access: Access::View, label: "Review".into() }),
|
||||
ClientMsg::Admin(Admin::Revoke { file: "f".into(), link: "l".into() }),
|
||||
ClientMsg::Admin(Admin::Remove { file: "f".into() }),
|
||||
ClientMsg::OpenLibrary(OpenLibrary { protocol: PROTOCOL, file: "f".into(), token: "t".into(), client_version: "0.3.1".into() }),
|
||||
ClientMsg::Library(LibraryReq::Fetch { have: Some(3), usage: Some(Usage { file: "Checkout".into(), components: vec![(NodeId(7), 12)] }) }),
|
||||
ClientMsg::Library(LibraryReq::Publish { archive: vec![0x50, 0x4B, 3, 4] }),
|
||||
ClientMsg::Library(LibraryReq::Usage),
|
||||
];
|
||||
let snapshot = Replica::new(Document::new("Snap")).snapshot().unwrap();
|
||||
let file = FileInfo { id: "f".into(), name: "Café".into(), created: 1, updated: 2, seq: 3, bytes: 4, people: 5 };
|
||||
@@ -94,6 +98,16 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
|
||||
ServerMsg::Admin(Box::new(AdminReply::Link(new_link))),
|
||||
ServerMsg::Admin(Box::new(AdminReply::Done)),
|
||||
ServerMsg::Admin(Box::new(AdminReply::Failed { reason: "no".into() })),
|
||||
ServerMsg::Library(Box::new(LibraryReply::Ready { access: Access::View })),
|
||||
ServerMsg::Library(Box::new(LibraryReply::Library { version: 2, archive: vec![1, 2, 3] })),
|
||||
ServerMsg::Library(Box::new(LibraryReply::Unchanged { version: 2 })),
|
||||
ServerMsg::Library(Box::new(LibraryReply::Empty)),
|
||||
ServerMsg::Library(Box::new(LibraryReply::Published { version: 3 })),
|
||||
ServerMsg::Library(Box::new(LibraryReply::Usage(vec![FileUsage {
|
||||
usage: Usage { file: "Checkout".into(), components: vec![(NodeId(7), 12)] },
|
||||
updated: 9,
|
||||
}]))),
|
||||
ServerMsg::Library(Box::new(LibraryReply::Failed { reason: "no".into() })),
|
||||
];
|
||||
(client, server)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::Error;
|
||||
use crate::change::Change;
|
||||
|
||||
/// The protocol version a client announces in [`Hello`] (2: the welcome says what you may do, and
|
||||
/// connections can manage a server).
|
||||
/// connections can manage a server or use a file's team library).
|
||||
pub const PROTOCOL: u32 = 2;
|
||||
|
||||
/// The first byte of every binary message.
|
||||
@@ -232,6 +232,74 @@ pub enum AdminReply {
|
||||
},
|
||||
}
|
||||
|
||||
/// Opens a connection that uses a file's team library (instead of [`Hello`]), with one of the
|
||||
/// file's share links. Answered with [`LibraryReply::Ready`] or `Refused`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct OpenLibrary {
|
||||
pub protocol: u32,
|
||||
pub file: String,
|
||||
pub token: String,
|
||||
pub client_version: String,
|
||||
}
|
||||
|
||||
/// How one file uses a library, as its app reports it: the file's name and its instances of each
|
||||
/// library component (by the component's id in the library).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Usage {
|
||||
pub file: String,
|
||||
pub components: Vec<(NodeId, u32)>,
|
||||
}
|
||||
|
||||
/// A file's last usage report, with when it came (seconds since 1970, UTC).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FileUsage {
|
||||
pub usage: Usage,
|
||||
pub updated: u64,
|
||||
}
|
||||
|
||||
/// A request on a library connection; each gets one [`LibraryReply`], in order.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum LibraryReq {
|
||||
/// The file's library, unless the server's version is `have`; `usage` reports how the asking
|
||||
/// file uses it.
|
||||
Fetch { have: Option<u64>, usage: Option<Usage> },
|
||||
/// Keep `archive` (a `.figcraft` archive holding the published library) as the file's library.
|
||||
/// Needs an edit link; the library must be the one kept (same id) at a newer version.
|
||||
Publish { archive: Vec<u8> },
|
||||
/// The usage reports of the files that use the library.
|
||||
Usage,
|
||||
}
|
||||
|
||||
/// The answer to a [`LibraryReq`] (the first one on a connection answers [`OpenLibrary`]).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum LibraryReply {
|
||||
/// The link is right: requests may follow, with this access.
|
||||
Ready {
|
||||
access: Access,
|
||||
},
|
||||
/// The library (a `.figcraft` archive holding it) at `version`.
|
||||
Library {
|
||||
version: u64,
|
||||
archive: Vec<u8>,
|
||||
},
|
||||
/// The server's version is the one asked about.
|
||||
Unchanged {
|
||||
version: u64,
|
||||
},
|
||||
/// The file has no library on the server.
|
||||
Empty,
|
||||
Published {
|
||||
version: u64,
|
||||
},
|
||||
Usage(Vec<FileUsage>),
|
||||
/// The request couldn't be done (the connection stays open).
|
||||
Failed {
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Client → server.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -254,6 +322,10 @@ pub enum ClientMsg {
|
||||
Manage(Manage),
|
||||
/// A request on a managing connection.
|
||||
Admin(Admin),
|
||||
/// Use a file's team library (the first message on a connection, instead of `Hello`).
|
||||
OpenLibrary(OpenLibrary),
|
||||
/// A request on a library connection.
|
||||
Library(LibraryReq),
|
||||
}
|
||||
|
||||
/// Server → client.
|
||||
@@ -299,6 +371,8 @@ pub enum ServerMsg {
|
||||
Pong,
|
||||
/// The answer to a managing connection's request.
|
||||
Admin(Box<AdminReply>),
|
||||
/// The answer to a library connection's request.
|
||||
Library(Box<LibraryReply>),
|
||||
}
|
||||
|
||||
/// The binary form of `msg`: [`WIRE_VERSION`], then postcard.
|
||||
|
||||
@@ -348,8 +348,8 @@ impl Server {
|
||||
Vec::new()
|
||||
}
|
||||
ClientMsg::Ping => vec![Outgoing::To(from, ServerMsg::Pong)],
|
||||
ClientMsg::Manage(_) | ClientMsg::Admin(_) => {
|
||||
vec![Outgoing::To(from, ServerMsg::Refused { reason: "managing files needs a connection of its own".into() })]
|
||||
ClientMsg::Manage(_) | ClientMsg::Admin(_) | ClientMsg::OpenLibrary(_) | ClientMsg::Library(_) => {
|
||||
vec![Outgoing::To(from, ServerMsg::Refused { reason: "managing files and libraries needs a connection of its own".into() })]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,7 +242,7 @@ pub(crate) fn place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// A component to instantiate: the component itself, or a set's default (first) variant.
|
||||
fn main_of(doc: &Document, id: NodeId) -> Option<NodeId> {
|
||||
pub(crate) fn main_of(doc: &Document, id: NodeId) -> Option<NodeId> {
|
||||
let n = doc.node(id)?;
|
||||
match n.kind {
|
||||
NodeKind::Component { .. } => Some(id),
|
||||
@@ -794,20 +794,28 @@ fn swap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets(cmd, s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?;
|
||||
let defaults = doc.node(c).map(default_props).unwrap_or_default();
|
||||
for i in ids {
|
||||
if doc.is_ancestor(c, i) {
|
||||
return Err(bad(cmd, "an instance can't show the component it is inside"));
|
||||
}
|
||||
if let NodeKind::Instance { component, props } = &mut doc.get_mut(i)?.kind {
|
||||
swap_instance(doc, i, c).map_err(|m| bad(cmd, m))?;
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Make instance `i` show main component `c`, keeping the values of properties with the same names
|
||||
/// (the layers' overrides follow by name when the instance is rebuilt).
|
||||
pub(crate) fn swap_instance(doc: &mut Document, i: NodeId, c: NodeId) -> std::result::Result<(), String> {
|
||||
if doc.is_ancestor(c, i) {
|
||||
return Err("an instance can't show the component it is inside".into());
|
||||
}
|
||||
let defaults = doc.node(c).map(default_props).unwrap_or_default();
|
||||
match doc.get_mut(i).map(|n| &mut n.kind) {
|
||||
Ok(NodeKind::Instance { component, props }) => {
|
||||
*component = c;
|
||||
let old = std::mem::take(props);
|
||||
*props = defaults.iter().map(|(k, d)| (k.clone(), old.get(k).cloned().unwrap_or_else(|| d.clone()))).collect();
|
||||
} else {
|
||||
return Err(bad(cmd, format!("{i} is not an instance")));
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(format!("{i} is not an instance")),
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Swap the instance under a page point to a component (`instance.swapAt`).
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Library commands: publish this file as a library; enable, use and update other library files
|
||||
//! (their components, styles and variables).
|
||||
//! Library commands: publish this file as a library (to its file, or to a server for the team);
|
||||
//! enable, use and update other libraries (their components, styles and variables), swap one for
|
||||
//! another, and see how files use a team library.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
@@ -7,8 +8,12 @@ use std::sync::Arc;
|
||||
use figcraft_doc::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use figcraft_collab::message::{LibraryReply, LibraryReq};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::library::*;
|
||||
use crate::library_server::{archive, is_server_link, read_archive, shown_path};
|
||||
use crate::live::PublishOptions;
|
||||
use crate::params::*;
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
@@ -35,7 +40,34 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
false,
|
||||
list
|
||||
),
|
||||
spec!("library.add", "Add library", "{path (.figcraft file published as a library)}", true, add),
|
||||
spec!(
|
||||
"library.publishToServer",
|
||||
"Publish library to server",
|
||||
"{server? (an edit link of this file on a figcraft-server; omit while this file is in a live session on a server, or give the server's address with its key to keep the file there first), key?, description?, include?, exclude? (as library.publish)} publishes the library as the next version of the one kept on the server under this file (per-asset versions as library.publish); files that enabled it with a link of the file see the update → {id, version, components, styles, collections, variables, file?, edit?, view? (links when the file was kept there now)}",
|
||||
false,
|
||||
publish_to_server
|
||||
),
|
||||
spec!(
|
||||
"library.add",
|
||||
"Add library",
|
||||
"{path (.figcraft file published as a library, or a share link of a file on a server: a view link is enough)}",
|
||||
true,
|
||||
add
|
||||
),
|
||||
spec!(
|
||||
"library.usage",
|
||||
"Library usage",
|
||||
"{library? (an enabled team library: id, name or link; omit for this file's library on its server)} → what files report: {id, name, instances, files: [{name, updated, instances}], components: [{id, name, instances, files}]}",
|
||||
false,
|
||||
usage_cmd
|
||||
),
|
||||
spec!(
|
||||
"library.swap",
|
||||
"Swap library",
|
||||
"{from (an enabled library: id, name or path), to (another enabled library)} re-points every component instance, style and variable copied from `from` to the published one of the same name in `to` (instances keep their overrides) → {components, styles, variables, unmatched: [names still from `from`], removed (nothing of `from` is used any more: it was removed)}",
|
||||
true,
|
||||
swap_cmd
|
||||
),
|
||||
spec!(
|
||||
"library.remove",
|
||||
"Remove library",
|
||||
@@ -157,11 +189,13 @@ fn library(s: &mut Session, cmd: &str, p: &Value) -> Result<(LibraryLink, Arc<Pu
|
||||
let link = find_link(s.doc()?, &key)
|
||||
.cloned()
|
||||
.ok_or_else(|| bad(cmd, format!("no enabled library `{key}` (library.list shows them; library.add enables one)")))?;
|
||||
let lib = s.libraries.load(&link.path)?;
|
||||
let lib = s.load_library(&link.path)?;
|
||||
if lib.manifest.id != link.id {
|
||||
return Err(EngineError::Other(format!(
|
||||
"{} now holds another library (\"{}\"): remove \"{}\" and add the file again",
|
||||
link.path, lib.manifest.name, link.name
|
||||
shown_path(&link.path),
|
||||
lib.manifest.name,
|
||||
link.name
|
||||
)));
|
||||
}
|
||||
Ok((link, lib))
|
||||
@@ -248,9 +282,9 @@ fn extend(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"id": id}))
|
||||
}
|
||||
|
||||
fn publish_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "library.publish";
|
||||
let doc = s.doc()?;
|
||||
/// What `p` asks `cmd` to publish of `doc` (`include`, else the default after `prev`; less
|
||||
/// `exclude`) and its description.
|
||||
fn publish_args(cmd: &str, doc: &Document, p: &Value, prev: Option<&LibraryManifest>) -> Result<(Selection, Option<String>)> {
|
||||
let all = publishable(doc);
|
||||
let coll_named = |c: &String, t: &str| doc.variables.collections.iter().any(|x| x.id == *c && x.name == t);
|
||||
let count = |s: &Selection| s.components.len() + s.styles.len() + s.collections.len();
|
||||
@@ -280,26 +314,149 @@ fn publish_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(sel)
|
||||
};
|
||||
let mut sel = if get(p, "include").is_some() { pick("include")? } else { default_selection(doc) };
|
||||
let mut sel = if get(p, "include").is_some() { pick("include")? } else { default_selection(doc, prev) };
|
||||
let x = pick("exclude")?;
|
||||
sel.components.retain(|c| !x.components.contains(c));
|
||||
sel.styles.retain(|s| !x.styles.contains(s));
|
||||
sel.collections.retain(|c| !x.collections.contains(c));
|
||||
let description = string(p, "description").map(|d| d.trim().chars().take(MAX_DESCRIPTION).collect());
|
||||
let lib = publish(doc, &sel, description, s.ds()?.uid)?;
|
||||
Ok((sel, description))
|
||||
}
|
||||
|
||||
/// What a publish answers.
|
||||
fn published_json(sel: &Selection, lib: &PublishedLibrary) -> Value {
|
||||
let m = &lib.manifest;
|
||||
let out = json!({
|
||||
json!({
|
||||
"id": m.id,
|
||||
"version": m.version,
|
||||
"components": sel.components.len(),
|
||||
"styles": sel.styles.len(),
|
||||
"collections": sel.collections.len(),
|
||||
"variables": m.variables.iter().filter(|v| v.published).count(),
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
fn publish_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let doc = s.doc()?;
|
||||
let prev = doc.published.clone();
|
||||
let (sel, description) = publish_args("library.publish", doc, p, prev.as_ref().map(|l| &l.manifest))?;
|
||||
let lib = publish(doc, prev.as_deref(), &sel, description, s.ds()?.uid)?;
|
||||
let out = published_json(&sel, &lib);
|
||||
s.doc_mut()?.published = Some(Arc::new(lib));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A library request's answer that isn't the one expected.
|
||||
fn unexpected(r: &LibraryReply) -> EngineError {
|
||||
EngineError::Other(format!("the server answered {r:?}"))
|
||||
}
|
||||
|
||||
fn publish_to_server(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "library.publishToServer";
|
||||
let key = string(p, "key").map(|k| k.trim().to_string()).filter(|k| !k.is_empty());
|
||||
let server = string(p, "server").map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
|
||||
let (doc, uid) = s.ds().map(|ds| (ds.doc.clone(), ds.uid))?;
|
||||
let mut kept = None;
|
||||
let link = match server {
|
||||
// The server's address with its key: the file is kept there first.
|
||||
Some(server) if key.is_some() || (server.contains("key=") && !server.contains("token=")) => {
|
||||
let opts = PublishOptions { server, key, name: doc.name.clone(), me: String::new(), doc: doc.clone() };
|
||||
let net = s.live.link().ok_or_else(|| EngineError::Other(crate::live::NO_TEAM_LIBRARIES.into()))?;
|
||||
let file = net.upload(opts).map_err(|e| bad(cmd, e))?;
|
||||
let edit = file.edit.clone();
|
||||
kept = Some(file);
|
||||
edit
|
||||
}
|
||||
Some(link) => link,
|
||||
None => s.server_link().ok_or_else(|| {
|
||||
bad(cmd, "give `server` (an edit link of this file on a server, or the server's address with its key), or publish this file to a server first (Share ▸ Publish to server…)")
|
||||
})?,
|
||||
};
|
||||
// The server's version is the one to follow (others may have published since).
|
||||
let current = match s.library_request(&link, LibraryReq::Fetch { have: None, usage: None })? {
|
||||
LibraryReply::Library { archive, .. } => Some(read_archive(&archive).map_err(EngineError::Other)?),
|
||||
LibraryReply::Empty => None,
|
||||
other => return Err(unexpected(&other)),
|
||||
};
|
||||
let prev = current.as_ref().or(doc.published.as_deref());
|
||||
let (sel, description) = publish_args(cmd, &doc, p, prev.map(|l| &l.manifest))?;
|
||||
let lib = match (publish(&doc, prev, &sel, description, uid), &doc.published) {
|
||||
(Ok(lib), _) => lib,
|
||||
// This file's library as last published here, not on the server yet: as it is.
|
||||
(Err(_), Some(own)) if current.is_none() && !sel.is_empty() => (**own).clone(),
|
||||
(Err(e), _) => return Err(e),
|
||||
};
|
||||
match s.library_request(&link, LibraryReq::Publish { archive: archive(&lib)? })? {
|
||||
LibraryReply::Published { .. } => {}
|
||||
other => return Err(unexpected(&other)),
|
||||
}
|
||||
let mut out = published_json(&sel, &lib);
|
||||
s.libraries.insert(&link, Ok(Arc::new(lib)));
|
||||
if let Some(f) = kept {
|
||||
out["file"] = json!(f.file);
|
||||
out["edit"] = json!(f.edit);
|
||||
out["view"] = json!(f.view);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn usage_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "library.usage";
|
||||
let link = match string(p, "library") {
|
||||
Some(key) => {
|
||||
let l = find_link(s.doc()?, &key).ok_or_else(|| bad(cmd, format!("no enabled library `{key}`")))?;
|
||||
if !is_server_link(&l.path) {
|
||||
return Err(bad(cmd, format!("\"{}\" is a library file, not a team library: only a server knows which files use it", l.name)));
|
||||
}
|
||||
l.path.clone()
|
||||
}
|
||||
None => s.server_link().ok_or_else(|| bad(cmd, "name an enabled team library, or open this file from its server"))?,
|
||||
};
|
||||
let lib = s.load_library(&link)?;
|
||||
let files = match s.library_request(&link, LibraryReq::Usage)? {
|
||||
LibraryReply::Usage(f) => f,
|
||||
other => return Err(unexpected(&other)),
|
||||
};
|
||||
let m = &lib.manifest;
|
||||
// Component → (instances, files using it).
|
||||
let mut per: std::collections::BTreeMap<NodeId, (u64, u64)> = std::collections::BTreeMap::new();
|
||||
let mut rows = Vec::new();
|
||||
for f in &files {
|
||||
let mut n = 0u64;
|
||||
for (c, k) in &f.usage.components {
|
||||
let e = per.entry(*c).or_default();
|
||||
e.0 += u64::from(*k);
|
||||
e.1 += 1;
|
||||
n += u64::from(*k);
|
||||
}
|
||||
rows.push(json!({"name": f.usage.file, "updated": f.updated, "instances": n}));
|
||||
}
|
||||
let mut comps: Vec<(NodeId, u64, u64)> = per.into_iter().map(|(c, (n, f))| (c, n, f)).collect();
|
||||
comps.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
|
||||
let components: Vec<Value> =
|
||||
comps.iter().map(|(c, n, f)| json!({"id": c, "name": m.component(*c).map(|a| a.name.clone()), "instances": n, "files": f})).collect();
|
||||
Ok(json!({
|
||||
"id": m.id,
|
||||
"name": m.name,
|
||||
"instances": comps.iter().map(|c| c.1).sum::<u64>(),
|
||||
"files": rows,
|
||||
"components": components,
|
||||
}))
|
||||
}
|
||||
|
||||
fn swap_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "library.swap";
|
||||
let from = req_string(cmd, p, "from")?;
|
||||
let from = find_link(s.doc()?, &from).map(|l| l.id.clone()).ok_or_else(|| bad(cmd, format!("no enabled library `{from}`")))?;
|
||||
let to = req_string(cmd, p, "to")?;
|
||||
let (link, lib) = library(s, cmd, &json!({"library": to}))?;
|
||||
if link.id == from {
|
||||
return Err(bad(cmd, "`from` and `to` are the same library"));
|
||||
}
|
||||
let r = crate::library_swap::swap_library(s.doc_mut()?, &from, &lib)?;
|
||||
Ok(json!({"components": r.components, "styles": r.styles, "variables": r.variables, "unmatched": r.unmatched, "removed": r.removed}))
|
||||
}
|
||||
|
||||
fn assets_json(lib: &PublishedLibrary) -> Value {
|
||||
let m = &lib.manifest;
|
||||
let modes = |c: &str| lib.doc.variables.collection(c).map(|c| c.modes.iter().map(|x| json!({"id": x.id, "name": x.name})).collect::<Vec<_>>());
|
||||
@@ -316,8 +473,8 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let links = s.doc()?.libraries.enabled.clone();
|
||||
let mut out = Vec::new();
|
||||
for l in links {
|
||||
let mut e = json!({"id": l.id, "name": l.name, "path": l.path, "seenVersion": l.version});
|
||||
match s.libraries.load(&l.path) {
|
||||
let mut e = json!({"id": l.id, "name": l.name, "path": l.path, "server": is_server_link(&l.path), "seenVersion": l.version});
|
||||
match s.fetch_library(&l.path, true) {
|
||||
Ok(lib) if lib.manifest.id == l.id => {
|
||||
e["version"] = json!(lib.manifest.version);
|
||||
e["description"] = json!(lib.manifest.description);
|
||||
@@ -344,7 +501,7 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
});
|
||||
let mut res = json!({"libraries": out, "published": published});
|
||||
if with_assets {
|
||||
let (def, all) = (default_selection(doc), publishable(doc));
|
||||
let (def, all) = (default_selection(doc, doc.published.as_ref().map(|l| &l.manifest)), publishable(doc));
|
||||
res["publishable"] = json!({
|
||||
"components": all.components.iter().filter_map(|c| doc.node(*c).map(|n| json!({"id": c, "name": n.name, "selected": def.components.contains(c)}))).collect::<Vec<_>>(),
|
||||
"styles": all.styles.iter().filter_map(|x| doc.style(x).map(|st| json!({"id": st.id, "name": st.name, "selected": def.styles.contains(x)}))).collect::<Vec<_>>(),
|
||||
@@ -356,8 +513,9 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "library.add";
|
||||
let path = absolute_path(req_string(cmd, p, "path")?.trim());
|
||||
let lib = s.libraries.load(&path)?;
|
||||
let raw = req_string(cmd, p, "path")?;
|
||||
let path = if is_server_link(&raw) { raw.trim().to_string() } else { absolute_path(raw.trim()) };
|
||||
let lib = s.load_library(&path)?;
|
||||
let m = &lib.manifest;
|
||||
let doc = s.doc()?;
|
||||
if doc.published.as_ref().is_some_and(|me| me.manifest.id == m.id) {
|
||||
@@ -397,7 +555,7 @@ fn updates(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let links = s.doc()?.libraries.enabled.clone();
|
||||
let (mut out, mut errors, mut count) = (Vec::new(), Vec::new(), 0);
|
||||
for l in links {
|
||||
let lib = match s.libraries.load(&l.path) {
|
||||
let lib = match s.fetch_library(&l.path, true) {
|
||||
Ok(lib) if lib.manifest.id == l.id => lib,
|
||||
Ok(_) => {
|
||||
errors.push(json!({"id": l.id, "name": l.name, "error": "the file now holds another library"}));
|
||||
|
||||
@@ -28,6 +28,8 @@ pub mod inspect;
|
||||
pub mod invite_links;
|
||||
pub mod keymap;
|
||||
pub mod library;
|
||||
pub mod library_server;
|
||||
pub mod library_swap;
|
||||
pub mod live;
|
||||
pub mod measure;
|
||||
pub mod params;
|
||||
|
||||
@@ -84,7 +84,7 @@ fn used_components(doc: &Document, root: NodeId) -> BTreeSet<NodeId> {
|
||||
}
|
||||
|
||||
/// The instance swap properties of component `c`.
|
||||
fn swap_props(doc: &Document, c: NodeId) -> Vec<String> {
|
||||
pub(crate) fn swap_props(doc: &Document, c: NodeId) -> Vec<String> {
|
||||
match doc.node(c).map(|n| &n.kind) {
|
||||
Some(NodeKind::Component { props, .. }) => {
|
||||
props.iter().filter(|(_, d)| matches!(d, PropDef::InstanceSwap { .. })).map(|(k, _)| k.clone()).collect()
|
||||
@@ -108,7 +108,8 @@ pub fn closure(doc: &Document, roots: &[NodeId]) -> Vec<NodeId> {
|
||||
out
|
||||
}
|
||||
|
||||
fn style_links(n: &Node) -> impl Iterator<Item = &String> {
|
||||
/// The style ids a layer links to (fill, stroke, effect, grid, text).
|
||||
pub(crate) fn style_links(n: &Node) -> impl Iterator<Item = &String> {
|
||||
[&n.styles.fill, &n.styles.stroke, &n.styles.effect, &n.styles.grid, &n.styles.text].into_iter().flatten()
|
||||
}
|
||||
|
||||
@@ -177,11 +178,10 @@ pub fn publishable(doc: &Document) -> Selection {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a publish offers when nothing is chosen: the assets offered last time plus new ones, never
|
||||
/// assets named with a leading `.` or `_`.
|
||||
pub fn default_selection(doc: &Document) -> Selection {
|
||||
/// What a publish offers when nothing is chosen: the assets offered last time (by `prev`, the
|
||||
/// version published last) plus new ones, never assets named with a leading `.` or `_`.
|
||||
pub fn default_selection(doc: &Document, prev: Option<&LibraryManifest>) -> Selection {
|
||||
let all = publishable(doc);
|
||||
let prev = doc.published.as_ref().map(|p| &p.manifest);
|
||||
let pick = |offered: Option<bool>, name: Option<&str>| offered.unwrap_or_else(|| name.is_some_and(|n| !hidden_name(n)));
|
||||
Selection {
|
||||
components: all
|
||||
@@ -253,10 +253,11 @@ fn new_library_id(doc: &Document, seed: u64) -> String {
|
||||
h.get(..16).map(str::to_string).unwrap_or(h)
|
||||
}
|
||||
|
||||
/// Publish `sel` (asset roots, styles and variable collections) of `doc` as a new library version.
|
||||
/// Publish `sel` (asset roots, styles and variable collections) of `doc` as the library version
|
||||
/// after `prev` (the file's last published version, here or on its server; `None`: the first).
|
||||
/// `seed` keeps a first publish's new id unique. Refused when there is nothing to publish or
|
||||
/// nothing changed.
|
||||
pub fn publish(doc: &Document, sel: &Selection, description: Option<String>, seed: u64) -> Result<PublishedLibrary> {
|
||||
pub fn publish(doc: &Document, prev: Option<&PublishedLibrary>, sel: &Selection, description: Option<String>, seed: u64) -> Result<PublishedLibrary> {
|
||||
let cmd = "library.publish";
|
||||
if sel.is_empty() {
|
||||
return Err(disabled(cmd, "nothing to publish: the file has no components, styles or variables to offer"));
|
||||
@@ -316,7 +317,6 @@ pub fn publish(doc: &Document, sel: &Selection, description: Option<String>, see
|
||||
}
|
||||
snap.take_dirty();
|
||||
// Versions: an asset or style keeps the version it last changed in.
|
||||
let prev = doc.published.as_deref();
|
||||
let version = prev.map_or(1, |p| p.manifest.version.saturating_add(1));
|
||||
let offered: BTreeSet<NodeId> = components.iter().copied().collect();
|
||||
let components = units
|
||||
@@ -424,8 +424,15 @@ impl Cache {
|
||||
self.load_limited(path, MAX_LIBRARY_FILE)
|
||||
}
|
||||
|
||||
/// [`load`](Self::load), refusing files larger than `max_bytes`.
|
||||
/// [`load`](Self::load), refusing files larger than `max_bytes`. A server library link gives
|
||||
/// what was last fetched ([`Session::load_library`](crate::Session::load_library) fetches).
|
||||
pub fn load_limited(&mut self, path: &str, max_bytes: u64) -> Result<Arc<PublishedLibrary>> {
|
||||
if crate::library_server::is_server_link(path) {
|
||||
return match self.cached(path) {
|
||||
Some(r) => r.cloned().map_err(|e| EngineError::Other(e.to_string())),
|
||||
None => Err(EngineError::Other(crate::library_server::NOT_FETCHED.into())),
|
||||
};
|
||||
}
|
||||
let stamp = stat(path);
|
||||
// Checked before the cache: the limit is the caller's.
|
||||
if let Ok(st) = &stamp
|
||||
@@ -453,6 +460,14 @@ impl Cache {
|
||||
pub fn cached(&self, path: &str) -> Option<std::result::Result<&Arc<PublishedLibrary>, &str>> {
|
||||
self.entries.get(path).map(|e| e.result.as_ref().map_err(String::as_str))
|
||||
}
|
||||
|
||||
/// Remember `result` for `path` (a library fetched from a server).
|
||||
pub fn insert(&mut self, path: &str, result: std::result::Result<Arc<PublishedLibrary>, String>) {
|
||||
if self.entries.len() >= MAX_CACHED && !self.entries.contains_key(path) {
|
||||
self.entries.clear();
|
||||
}
|
||||
self.entries.insert(path.to_string(), Entry { stamp: None, result });
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
//! Team libraries: a file's published library kept on a `figcraft-server` under that file, and
|
||||
//! used by other files through one of its share links (a view link is enough to read it).
|
||||
//!
|
||||
//! A library on a server is the same published library a library file holds (manifest and
|
||||
//! snapshot, with per-asset versions), carried as a small `.figcraft` archive holding only it. Its
|
||||
//! link takes the place of a library file's path in [`LibraryLink::path`], so everything else
|
||||
//! (the Assets panel, placing components, updates with overrides kept) works as for library files.
|
||||
//! The engine never opens a socket: requests go through the app's [`LiveLink`](crate::live::LiveLink).
|
||||
//! A command that needs a library it hasn't fetched yet waits for the server; the Assets panel
|
||||
//! refreshes in the background ([`Session::refresh_server_libraries`]) and keeps the last library
|
||||
//! fetched while the server can't be reached.
|
||||
|
||||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_collab::message::{LibraryReply, LibraryReq, Usage};
|
||||
use figcraft_doc::{Document, NodeId, NodeKind, PublishedLibrary};
|
||||
|
||||
use crate::live::NO_TEAM_LIBRARIES;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// What a server library that hasn't been fetched yet says.
|
||||
pub const NOT_FETCHED: &str = "the library hasn't been fetched from its server yet";
|
||||
/// What a server file without a library says.
|
||||
pub const NO_LIBRARY: &str = "that file has no library on the server yet: publish it there first (Libraries ▸ Publish to server)";
|
||||
/// Most components one usage report lists.
|
||||
pub const MAX_USAGE: usize = 10_000;
|
||||
|
||||
/// Whether a library's path is a share link of a file on a server (else it is a library file).
|
||||
pub fn is_server_link(path: &str) -> bool {
|
||||
let p = path.trim_start().to_ascii_lowercase();
|
||||
["figcraft://", "ws://", "wss://"].iter().any(|s| p.starts_with(s))
|
||||
}
|
||||
|
||||
/// A library path to show: a server link without its token.
|
||||
pub fn shown_path(path: &str) -> String {
|
||||
if !is_server_link(path) {
|
||||
return path.to_string();
|
||||
}
|
||||
let Some(at) = path.find("token=") else { return path.to_string() };
|
||||
let end = path.get(at..).and_then(|rest| rest.find('&')).map_or(path.len(), |e| at + e);
|
||||
format!("{}token=…{}", path.get(..at).unwrap_or_default(), path.get(end..).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// `lib` as the archive a server keeps: a `.figcraft` file holding only the library.
|
||||
pub fn archive(lib: &PublishedLibrary) -> Result<Vec<u8>> {
|
||||
let mut holder = Document::new(lib.manifest.name.clone());
|
||||
holder.published = Some(Arc::new(lib.clone()));
|
||||
figcraft_format::save_bytes(&holder, None).map_err(|e| EngineError::Other(format!("couldn't pack the library: {e}")))
|
||||
}
|
||||
|
||||
/// The library in an archive from a server (untrusted: read with the library file's limits).
|
||||
pub fn read_archive(bytes: &[u8]) -> std::result::Result<PublishedLibrary, String> {
|
||||
match figcraft_format::read_library(Cursor::new(bytes)) {
|
||||
Ok(Some(lib)) => Ok(lib),
|
||||
Ok(None) => Err("the server sent a file without a library".into()),
|
||||
Err(e) => Err(format!("the server sent a library that can't be read ({e})")),
|
||||
}
|
||||
}
|
||||
|
||||
/// How `doc` uses library `library`: its instances of each of the library's components (outside
|
||||
/// the library copies themselves), as the server's usage report.
|
||||
// shortcut: files are told apart by their names (a file has no id of its own) and a file that
|
||||
// removes the library stays in the reports; send a file id and a removal once files have ids.
|
||||
pub fn usage(doc: &Document, library: &str) -> Usage {
|
||||
let store = doc.library_store();
|
||||
let mut counts = std::collections::BTreeMap::<NodeId, u32>::new();
|
||||
let records: std::collections::BTreeMap<NodeId, NodeId> =
|
||||
doc.libraries.components.iter().filter(|r| r.library == library).map(|r| (r.root, r.key)).collect();
|
||||
if !records.is_empty() {
|
||||
for n in doc.nodes() {
|
||||
if let NodeKind::Instance { component, .. } = &n.kind
|
||||
&& store.is_none_or(|st| doc.page_of(n.id) != Some(st))
|
||||
&& let Some(key) = crate::library::asset_root(doc, *component).and_then(|r| records.get(&r))
|
||||
{
|
||||
let c = counts.entry(*key).or_default();
|
||||
*c = c.saturating_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
Usage { file: doc.name.clone(), components: counts.into_iter().take(MAX_USAGE).collect() }
|
||||
}
|
||||
|
||||
/// A reply that doesn't answer the request.
|
||||
fn unexpected(r: &LibraryReply) -> EngineError {
|
||||
EngineError::Other(format!("the server answered {r:?}"))
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The share link of the active document's live session when the document is a server file
|
||||
/// you may edit (where `library.publishToServer` publishes by default).
|
||||
pub fn server_link(&self) -> Option<String> {
|
||||
let uid = self.ds().ok()?.uid;
|
||||
let info = self.live.info.as_ref().filter(|i| i.role == crate::live::Role::Guest && i.access == crate::live::Access::Edit)?;
|
||||
self.live_doc(uid).then(|| info.link.clone())
|
||||
}
|
||||
|
||||
/// One request about the team library behind share link `link`, waiting for the server (a
|
||||
/// refusal is an error).
|
||||
// shortcut: commands wait on the calling thread (at most the 5 s connect timeout when the server
|
||||
// is down; the panel's checks run in the background); queue them on the link if that shows.
|
||||
pub(crate) fn library_request(&mut self, link: &str, req: LibraryReq) -> Result<LibraryReply> {
|
||||
let net = self.live.link().ok_or_else(|| EngineError::Other(NO_TEAM_LIBRARIES.into()))?;
|
||||
match net.library(link, req).map_err(EngineError::Other)? {
|
||||
LibraryReply::Failed { reason } => Err(EngineError::Other(reason)),
|
||||
r => Ok(r),
|
||||
}
|
||||
}
|
||||
|
||||
/// The published library at `path`: a library file (read again when it changed), or a server
|
||||
/// library link (fetched once; then the background refresh keeps it current).
|
||||
pub fn load_library(&mut self, path: &str) -> Result<Arc<PublishedLibrary>> {
|
||||
self.fetch_library(path, false)
|
||||
}
|
||||
|
||||
/// [`load_library`](Self::load_library); with `fresh`, a team library's server is asked for a
|
||||
/// newer version first (`library.updates`, `library.list`). Fetches report how the active
|
||||
/// document uses the library.
|
||||
pub fn fetch_library(&mut self, path: &str, fresh: bool) -> Result<Arc<PublishedLibrary>> {
|
||||
if !is_server_link(path) {
|
||||
return self.libraries.load(path);
|
||||
}
|
||||
let have = match self.libraries.cached(path) {
|
||||
Some(Ok(lib)) if !fresh => return Ok(lib.clone()),
|
||||
Some(Ok(lib)) => Some(lib.manifest.version),
|
||||
_ => None,
|
||||
};
|
||||
let usage = self.doc().ok().map(|d| {
|
||||
let id = d.libraries.enabled.iter().find(|l| l.path == path).map(|l| l.id.clone()).unwrap_or_default();
|
||||
usage(d, &id)
|
||||
});
|
||||
let reply = match self.live.link() {
|
||||
Some(net) => net.library(path, LibraryReq::Fetch { have, usage }),
|
||||
None => Err(NO_TEAM_LIBRARIES.into()),
|
||||
};
|
||||
self.take_library(path, reply).map_err(EngineError::Other)
|
||||
}
|
||||
|
||||
/// Ask the servers of the active document's team libraries for newer versions, in the
|
||||
/// background, reporting how the document uses each (the Assets panel calls it now and then).
|
||||
pub fn refresh_server_libraries(&mut self) {
|
||||
let Ok(doc) = self.doc() else { return };
|
||||
let jobs: Vec<(String, LibraryReq)> = doc
|
||||
.libraries
|
||||
.enabled
|
||||
.iter()
|
||||
.filter(|l| is_server_link(&l.path))
|
||||
.map(|l| {
|
||||
let have = match self.libraries.cached(&l.path) {
|
||||
Some(Ok(lib)) => Some(lib.manifest.version),
|
||||
_ => None,
|
||||
};
|
||||
(l.path.clone(), LibraryReq::Fetch { have, usage: Some(usage(doc, &l.id)) })
|
||||
})
|
||||
.collect();
|
||||
if let (false, Some(net)) = (jobs.is_empty(), self.live.link()) {
|
||||
net.library_background(jobs);
|
||||
}
|
||||
}
|
||||
|
||||
/// A background library request was answered: a newer library replaces the one shown ("Library
|
||||
/// updates available" follows).
|
||||
pub(crate) fn library_answer(&mut self, link: &str, reply: std::result::Result<LibraryReply, String>) {
|
||||
let _ = self.take_library(link, reply);
|
||||
}
|
||||
|
||||
/// Take the answer to a fetch of the team library behind `link` into the cache. While the
|
||||
/// server can't be reached (or refuses) the last library fetched stays in use.
|
||||
fn take_library(&mut self, link: &str, reply: std::result::Result<LibraryReply, String>) -> std::result::Result<Arc<PublishedLibrary>, String> {
|
||||
let kept = match self.libraries.cached(link) {
|
||||
Some(Ok(lib)) => Some(lib.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let result = match reply {
|
||||
Ok(LibraryReply::Library { archive, .. }) => read_archive(&archive).map(Arc::new),
|
||||
Ok(LibraryReply::Unchanged { .. }) => kept.clone().ok_or_else(|| NOT_FETCHED.to_string()),
|
||||
Ok(LibraryReply::Empty) => Err(NO_LIBRARY.into()),
|
||||
Ok(LibraryReply::Failed { reason }) | Err(reason) => Err(reason),
|
||||
Ok(other) => Err(unexpected(&other).to_string()),
|
||||
};
|
||||
match (result, kept) {
|
||||
(Err(e), Some(lib)) => {
|
||||
log::info!("team library {}: {e}; the last version fetched stays in use", shown_path(link));
|
||||
Ok(lib)
|
||||
}
|
||||
(result, _) => {
|
||||
self.libraries.insert(link, result.clone());
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "library_server_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,27 @@
|
||||
//! Team library helpers: links, what is shown of them, and the archive a server keeps.
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn links_archives_and_junk() {
|
||||
assert!(is_server_link("figcraft://join?host=h:1&file=f&token=t") && is_server_link(" WS://h:1/?file=f&token=t"));
|
||||
assert!(!is_server_link("C:/kits/ui.figcraft") && !is_server_link("/srv/ws://x"));
|
||||
assert_eq!(shown_path("figcraft://join?host=h:1&file=f&token=secret&x=1"), "figcraft://join?host=h:1&file=f&token=…&x=1");
|
||||
assert_eq!(shown_path("ws://h:1/?file=f&token=secret"), "ws://h:1/?file=f&token=…");
|
||||
assert_eq!(shown_path("/kits/token=ui.figcraft"), "/kits/token=ui.figcraft", "files are shown as they are");
|
||||
|
||||
let mut s = Session::new();
|
||||
let f = s.execute("frame.create", &serde_json::json!({"x": 0, "y": 0, "width": 10, "height": 10, "name": "Chip"})).unwrap();
|
||||
s.execute("component.create", &serde_json::json!({"ids": [f["id"]]})).unwrap();
|
||||
s.execute("library.publish", &serde_json::json!({})).unwrap();
|
||||
let lib = s.doc().unwrap().published.clone().unwrap();
|
||||
let back = read_archive(&archive(&lib).unwrap()).unwrap();
|
||||
assert_eq!(back.manifest, lib.manifest);
|
||||
assert!(read_archive(b"not a zip").is_err());
|
||||
let plain = figcraft_format::save_bytes(&Document::new("x"), None).unwrap();
|
||||
assert!(read_archive(&plain).unwrap_err().contains("without a library"));
|
||||
// Without the app's networking, a team library is a clear error.
|
||||
assert!(
|
||||
s.execute("library.add", &serde_json::json!({"path": "ws://127.0.0.1:9/?file=f&token=t"})).unwrap_err().to_string().contains("desktop app")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
//! Swapping a library: every copy of library A's components, styles and variables that a document
|
||||
//! uses is re-pointed to the asset of the same name in library B (a design system moved to another
|
||||
//! library, or a theme library swapped for another). Instances keep their overrides (by layer and
|
||||
//! property names, as when swapping an instance), styled and bound layers follow, and what B has
|
||||
//! no match for stays on A and is reported.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use figcraft_doc::*;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::cmd::component::{main_of, swap_instance};
|
||||
use crate::components::inside_instance;
|
||||
use crate::library::{asset_root, import, import_styles, import_variables, prune, style_links, swap_props};
|
||||
use crate::params::id_of;
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
/// What a swap did: how many copies moved to the other library, and the names of those it has no
|
||||
/// match for (still from the old library).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Swapped {
|
||||
pub components: usize,
|
||||
pub styles: usize,
|
||||
pub variables: usize,
|
||||
pub unmatched: Vec<String>,
|
||||
/// Nothing of the old library is used any more: it was removed from the document.
|
||||
pub removed: bool,
|
||||
}
|
||||
|
||||
/// The main component a layer of A's copy `a_main` maps to in B's copy rooted at `b_root`: the
|
||||
/// variant of the same name in a set, else the component itself (a set's default variant when B
|
||||
/// has a set for a plain component).
|
||||
fn b_main(doc: &Document, a_main: NodeId, a_root: NodeId, b_root: NodeId) -> Option<NodeId> {
|
||||
let b = doc.node(b_root)?;
|
||||
match (&b.kind, a_main == a_root) {
|
||||
(NodeKind::ComponentSet { .. }, false) => {
|
||||
let name = &doc.node(a_main)?.name;
|
||||
b.children.iter().copied().find(|c| doc.node(*c).is_some_and(|n| n.name == *name && matches!(n.kind, NodeKind::Component { .. })))
|
||||
}
|
||||
_ => main_of(doc, b_root),
|
||||
}
|
||||
}
|
||||
|
||||
/// Swap the copies of library `from` in `doc` to library `to` by names (see the module docs).
|
||||
pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Result<Swapped> {
|
||||
let m = &to.manifest;
|
||||
if m.id == from {
|
||||
return Err(EngineError::Other("that is the same library".into()));
|
||||
}
|
||||
// Only what the document uses counts (copies nothing uses any more go first).
|
||||
prune(doc, from)?;
|
||||
let mut out = Swapped::default();
|
||||
let store = doc.library_store();
|
||||
let outside = |d: &Document, id: NodeId| store.is_none_or(|s| d.page_of(id) != Some(s));
|
||||
|
||||
// Components: A's copies the document's own instances show (those only other copies use go
|
||||
// with them), matched by name to B's published components.
|
||||
let shown: BTreeSet<NodeId> = doc
|
||||
.nodes()
|
||||
.filter(|n| outside(doc, n.id) && !inside_instance(doc, n.id))
|
||||
.filter_map(|n| match &n.kind {
|
||||
NodeKind::Instance { component, .. } => asset_root(doc, *component),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let records: Vec<ImportedComponent> = doc.libraries.components.iter().filter(|r| r.library == from && shown.contains(&r.root)).cloned().collect();
|
||||
let mut pairs = Vec::new();
|
||||
for r in &records {
|
||||
let name = doc.node(r.root).map(|n| n.name.clone()).unwrap_or_default();
|
||||
match m.components.iter().find(|a| a.published && a.name == name) {
|
||||
Some(a) => pairs.push((r.root, a.id)),
|
||||
None => out.unmatched.push(format!("component {name}")),
|
||||
}
|
||||
}
|
||||
let keys: Vec<NodeId> = pairs.iter().map(|(_, k)| *k).collect();
|
||||
let map = if keys.is_empty() { BTreeMap::new() } else { import(doc, to, &keys, false)? };
|
||||
// A's mains (each variant) and roots → B's.
|
||||
let mut mains: BTreeMap<NodeId, NodeId> = BTreeMap::new();
|
||||
let mut roots: BTreeMap<NodeId, NodeId> = BTreeMap::new();
|
||||
for (a_root, key) in &pairs {
|
||||
let Some(b_root) = map.get(key).copied() else { continue };
|
||||
roots.insert(*a_root, b_root);
|
||||
let a_mains: Vec<NodeId> = match doc.node(*a_root).map(|n| &n.kind) {
|
||||
Some(NodeKind::ComponentSet { .. }) => doc.node(*a_root).map(|n| n.children.clone()).unwrap_or_default(),
|
||||
_ => vec![*a_root],
|
||||
};
|
||||
let before = mains.len();
|
||||
for a in a_mains {
|
||||
match b_main(doc, a, *a_root, b_root) {
|
||||
Some(b) => {
|
||||
mains.insert(a, b);
|
||||
}
|
||||
None => out.unmatched.push(format!("variant {}", doc.node(a).map(|n| n.name.as_str()).unwrap_or_default())),
|
||||
}
|
||||
}
|
||||
if mains.len() > before {
|
||||
out.components += 1;
|
||||
}
|
||||
}
|
||||
let instances: Vec<(NodeId, NodeId)> = doc
|
||||
.nodes()
|
||||
.filter(|n| outside(doc, n.id) && !inside_instance(doc, n.id))
|
||||
.filter_map(|n| match &n.kind {
|
||||
NodeKind::Instance { component, .. } => mains.get(component).map(|b| (n.id, *b)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for (inst, b) in instances {
|
||||
swap_instance(doc, inst, b).map_err(EngineError::Other)?;
|
||||
// Instance swap values naming A's components name B's.
|
||||
let swaps = swap_props(doc, b);
|
||||
if let Ok(NodeKind::Instance { props, .. }) = doc.get_mut(inst).map(|n| &mut n.kind) {
|
||||
for k in swaps {
|
||||
if let Some(to) = props.get(&k).and_then(id_of).and_then(|c| mains.get(&c).or_else(|| roots.get(&c))) {
|
||||
props.insert(k, Value::from(to.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Styles the document's own layers link to: by name and type.
|
||||
let linked: BTreeSet<String> = doc.nodes().filter(|n| outside(doc, n.id)).flat_map(|n| style_links(n).cloned()).collect();
|
||||
let style_recs: Vec<ImportedRecord> = doc.libraries.styles.iter().filter(|r| r.library == from && linked.contains(&r.local)).cloned().collect();
|
||||
let mut style_keys: BTreeMap<String, String> = BTreeMap::new();
|
||||
for r in &style_recs {
|
||||
let Some(st) = doc.style(&r.local) else { continue };
|
||||
let kind = st.kind.type_name();
|
||||
match m.styles.iter().find(|x| x.published && x.name == st.name && x.kind == kind) {
|
||||
Some(x) => {
|
||||
style_keys.insert(r.local.clone(), x.id.clone());
|
||||
}
|
||||
None => out.unmatched.push(format!("style {}", st.name)),
|
||||
}
|
||||
}
|
||||
let imported = import_styles(doc, to, &style_keys.values().cloned().collect(), false);
|
||||
let styles: BTreeMap<String, String> = style_keys.iter().filter_map(|(a, k)| imported.get(k).map(|b| (a.clone(), b.clone()))).collect();
|
||||
out.styles = styles.len();
|
||||
|
||||
// Variables: by collection and variable name and type; collections and modes by name.
|
||||
let coll_name = |d: &Document, id: &str| d.variables.collection(id).map(|c| c.name.clone()).unwrap_or_default();
|
||||
// Variables the document's own layers bind or its own variables alias.
|
||||
let remote: BTreeSet<String> = doc.libraries.variables.iter().map(|r| r.local.clone()).collect();
|
||||
let aliases = doc.variables.variables.iter().filter(|v| !remote.contains(&v.id)).flat_map(|v| v.values.values()).filter_map(|x| match x {
|
||||
VariableValue::Alias(a) => Some(a.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let used: BTreeSet<String> = doc.nodes().filter(|n| outside(doc, n.id)).flat_map(|n| n.bound.values().cloned()).chain(aliases).collect();
|
||||
let var_recs: Vec<ImportedRecord> = doc.libraries.variables.iter().filter(|r| r.library == from && used.contains(&r.local)).cloned().collect();
|
||||
let lib_coll = |name: &str| to.doc.variables.collections.iter().find(|c| c.name == name && m.collection(&c.id).is_some_and(|x| x.published));
|
||||
let mut var_keys: BTreeMap<String, String> = BTreeMap::new();
|
||||
for r in &var_recs {
|
||||
let Some(v) = doc.variables.get(&r.local) else { continue };
|
||||
let cname = coll_name(doc, &v.collection);
|
||||
let found = lib_coll(&cname).and_then(|c| {
|
||||
to.doc
|
||||
.variables
|
||||
.variables
|
||||
.iter()
|
||||
.find(|x| x.collection == c.id && x.name == v.name && x.kind == v.kind && m.variable(&x.id).is_some_and(|y| y.published))
|
||||
});
|
||||
match found {
|
||||
Some(x) => {
|
||||
var_keys.insert(r.local.clone(), x.id.clone());
|
||||
}
|
||||
None => out.unmatched.push(format!("variable {cname}/{}", v.name)),
|
||||
}
|
||||
}
|
||||
let coll_keys: BTreeMap<String, String> = doc
|
||||
.libraries
|
||||
.collections
|
||||
.iter()
|
||||
.filter(|r| r.library == from)
|
||||
.filter_map(|r| lib_coll(&coll_name(doc, &r.local)).map(|c| (r.local.clone(), c.id.clone())))
|
||||
.collect();
|
||||
let vmap = import_variables(doc, to, &var_keys.values().cloned().collect(), &coll_keys.values().cloned().collect(), false);
|
||||
let vars: BTreeMap<String, String> = var_keys.iter().filter_map(|(a, k)| vmap.variables.get(k).map(|b| (a.clone(), b.clone()))).collect();
|
||||
// A's collection → (B's, A's mode id → B's mode of the same name).
|
||||
let mut colls: BTreeMap<String, (String, BTreeMap<String, String>)> = BTreeMap::new();
|
||||
for (a, k) in &coll_keys {
|
||||
let (Some(b), Some(ac)) = (vmap.collections.get(k), doc.variables.collection(a)) else { continue };
|
||||
let modes = doc
|
||||
.variables
|
||||
.collection(b)
|
||||
.map(|bc| {
|
||||
ac.modes.iter().filter_map(|am| bc.modes.iter().find(|bm| bm.name == am.name).map(|bm| (am.id.clone(), bm.id.clone()))).collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
colls.insert(a.clone(), (b.clone(), modes));
|
||||
}
|
||||
out.variables = vars.len();
|
||||
// shortcut: this file's collections extending one of A's stay on A (listed); move them with their
|
||||
// overrides (mode ids differ between libraries) when swapping themes needs it.
|
||||
let a_colls: BTreeSet<&str> = doc.libraries.collections.iter().filter(|r| r.library == from).map(|r| r.local.as_str()).collect();
|
||||
for c in doc.variables.collections.iter().filter(|c| c.extends.as_deref().is_some_and(|e| a_colls.contains(e))) {
|
||||
out.unmatched.push(format!("extended collection {}", c.name));
|
||||
}
|
||||
|
||||
// Re-point layers outside the library copies, and local variables aliasing A's.
|
||||
let ids: Vec<NodeId> = doc.nodes().filter(|n| outside(doc, n.id)).map(|n| n.id).collect();
|
||||
for id in ids {
|
||||
let Ok(n) = doc.get_mut(id) else { continue };
|
||||
for r in [&mut n.styles.fill, &mut n.styles.stroke, &mut n.styles.effect, &mut n.styles.grid, &mut n.styles.text] {
|
||||
if let Some(b) = r.as_ref().and_then(|s| styles.get(s)) {
|
||||
*r = Some(b.clone());
|
||||
}
|
||||
}
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
t.map_text_styles(|s| Some(styles.get(s).cloned().unwrap_or_else(|| s.to_string())));
|
||||
}
|
||||
for v in n.bound.values_mut() {
|
||||
if let Some(b) = vars.get(v) {
|
||||
*v = b.clone();
|
||||
}
|
||||
}
|
||||
n.var_modes = std::mem::take(&mut n.var_modes)
|
||||
.into_iter()
|
||||
.map(|(c, mode)| match colls.get(&c).and_then(|(b, modes)| modes.get(&mode).map(|bm| (b.clone(), bm.clone()))) {
|
||||
Some(moved) => moved,
|
||||
None => (c, mode),
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
for v in doc.variables.variables.iter_mut().filter(|v| !remote.contains(&v.id)) {
|
||||
for val in v.values.values_mut() {
|
||||
if let VariableValue::Alias(a) = val
|
||||
&& let Some(b) = vars.get(a)
|
||||
{
|
||||
*a = b.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A's copies nothing uses any more go; A goes too once nothing of it is left.
|
||||
prune(doc, from)?;
|
||||
let libs = &doc.libraries;
|
||||
let left = libs.components.iter().any(|r| r.library == from)
|
||||
|| libs.styles.iter().any(|r| r.library == from)
|
||||
|| libs.variables.iter().any(|r| r.library == from)
|
||||
|| libs.collections.iter().any(|r| r.library == from);
|
||||
if !left {
|
||||
doc.libraries_mut().enabled.retain(|l| l.id != from);
|
||||
out.removed = true;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "library_swap_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Swapping a library: instances, styles and variables move to the other library's assets of the
|
||||
//! same names (overrides kept), what has no match stays and is reported, one undo step.
|
||||
|
||||
use figcraft_doc::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::library::{is_remote_style, library_of};
|
||||
use crate::params::id_of;
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
match s.execute(cmd, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{cmd} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn idv(v: &Value) -> NodeId {
|
||||
id_of(&v["id"]).unwrap()
|
||||
}
|
||||
|
||||
/// A scratch directory, removed when dropped.
|
||||
struct Scratch(std::path::PathBuf);
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A library file at `path` with "Button" (filled `color`, a "Label" text), a "Brand" paint style,
|
||||
/// a "Theme" collection with an `accent` colour, and `extra` components; returns its id.
|
||||
fn library(path: &str, color: &str, extra: &[&str]) -> String {
|
||||
let mut s = Session::new();
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 40, "name": "Button"})));
|
||||
ex(&mut s, "text.create", json!({"text": "OK", "x": 10, "y": 10, "parent": f, "props": {"name": "Label"}}));
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {"fill": color}}));
|
||||
ex(&mut s, "component.create", json!({"ids": [f]}));
|
||||
let r = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 100, "width": 10, "height": 10})));
|
||||
ex(&mut s, "node.set", json!({"id": r, "props": {"fill": color}}));
|
||||
ex(&mut s, "style.create", json!({"type": "paint", "name": "Brand", "from": r}));
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Theme", "modes": ["Light", "Dark"]}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "accent", "type": "color", "value": color}));
|
||||
for (i, name) in extra.iter().enumerate() {
|
||||
let e = idv(&ex(&mut s, "frame.create", json!({"x": 200 + 50 * i, "y": 0, "width": 20, "height": 20, "name": name})));
|
||||
ex(&mut s, "component.create", json!({"ids": [e]}));
|
||||
}
|
||||
let id = ex(&mut s, "library.publish", json!({}))["id"].as_str().unwrap().to_string();
|
||||
ex(&mut s, "file.saveAs", json!({"path": path}));
|
||||
id
|
||||
}
|
||||
|
||||
fn main_of(s: &Session, inst: NodeId) -> NodeId {
|
||||
match s.doc().unwrap().node(inst).unwrap().kind {
|
||||
NodeKind::Instance { component, .. } => component,
|
||||
_ => panic!("not an instance"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swap_moves_instances_styles_and_variables_by_name() {
|
||||
let dir = Scratch(std::env::temp_dir().join(format!("figcraft-libswap-{}", std::process::id())));
|
||||
std::fs::create_dir_all(&dir.0).unwrap();
|
||||
let (pa, pb) = (dir.0.join("a.figcraft").to_string_lossy().into_owned(), dir.0.join("b.figcraft").to_string_lossy().into_owned());
|
||||
let a = library(&pa, "#0000ff", &["Only in A"]);
|
||||
let b = library(&pb, "#00ff00", &[]);
|
||||
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "library.add", json!({"path": pa}));
|
||||
ex(&mut s, "library.add", json!({"path": pb}));
|
||||
let inst = idv(&ex(&mut s, "library.place", json!({"library": a, "component": "Button", "x": 0, "y": 0})));
|
||||
let label = s.doc().unwrap().node(inst).unwrap().children[0];
|
||||
ex(&mut s, "text.set", json!({"id": label, "characters": "Buy"}));
|
||||
let only = idv(&ex(&mut s, "library.place", json!({"library": a, "component": "Only in A", "x": 300, "y": 0})));
|
||||
let rect = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 200, "width": 10, "height": 10})));
|
||||
ex(&mut s, "library.applyStyle", json!({"library": a, "style": "Brand", "ids": [rect]}));
|
||||
let dot = idv(&ex(&mut s, "shape.rectangle", json!({"x": 50, "y": 200, "width": 10, "height": 10})));
|
||||
ex(&mut s, "library.bindVariable", json!({"library": a, "variable": "accent", "field": "fill", "ids": [dot]}));
|
||||
let extended = ex(&mut s, "library.extendCollection", json!({"library": a, "collection": "Theme"}))["id"].clone();
|
||||
let before = s.doc().unwrap().clone();
|
||||
|
||||
let r = ex(&mut s, "library.swap", json!({"from": a, "to": b}));
|
||||
assert_eq!((r["components"].as_u64(), r["styles"].as_u64(), r["variables"].as_u64()), (Some(1), Some(1), Some(1)), "{r}");
|
||||
assert_eq!(r["unmatched"], json!(["component Only in A", "extended collection Theme (extended)"]));
|
||||
assert_eq!(r["removed"], false, "Only in A and the extension still use library A");
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(library_of(doc, main_of(&s, inst)), Some(b.as_str()), "the instance shows B's Button");
|
||||
assert_eq!(library_of(doc, main_of(&s, only)), Some(a.as_str()), "no match: still A's");
|
||||
let label = doc.node(inst).unwrap().children[0];
|
||||
assert_eq!(doc.node(label).unwrap().kind.text().map(|t| t.characters.as_str()), Some("Buy"), "the override stays");
|
||||
let fill = |id: NodeId| doc.node(id).unwrap().fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).unwrap_or_default();
|
||||
assert_eq!(fill(inst), "00FF00", "B's Button");
|
||||
let style = doc.node(rect).unwrap().styles.fill.clone().unwrap();
|
||||
assert!(is_remote_style(doc, &style) && doc.libraries.style_import(&style).is_some_and(|r| r.library == b));
|
||||
assert_eq!(fill(rect), "00FF00", "the style's paint follows");
|
||||
let var = doc.node(dot).unwrap().bound.get("fills/0/color").or_else(|| doc.node(dot).unwrap().bound.values().next()).cloned().unwrap();
|
||||
assert!(doc.libraries.variable_import(&var).is_some_and(|r| r.library == b), "bound to B's accent");
|
||||
assert!(doc.libraries.styles.iter().all(|r| r.library == b), "A's unused style copy is gone");
|
||||
assert!(doc.validate().is_empty(), "{:?}", doc.validate());
|
||||
|
||||
// One undo step brings A back.
|
||||
s.undo().unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(library_of(doc, main_of(&s, inst)), Some(a.as_str()));
|
||||
assert_eq!(doc.libraries.as_ref(), before.libraries.as_ref());
|
||||
|
||||
// Swapping what has a match for everything removes the old library.
|
||||
ex(&mut s, "edit.delete", json!({"ids": [only]}));
|
||||
ex(&mut s, "variable.deleteCollection", json!({"collection": extended}));
|
||||
let r = ex(&mut s, "library.swap", json!({"from": a, "to": b}));
|
||||
assert_eq!((r["unmatched"].as_array().map(Vec::len), &r["removed"]), (Some(0), &json!(true)), "{r}");
|
||||
assert!(s.doc().unwrap().libraries.enabled.iter().all(|l| l.id != a));
|
||||
// The same library, or one not enabled, is refused.
|
||||
assert!(s.execute("library.swap", &json!({"from": b, "to": b})).is_err());
|
||||
assert!(s.execute("library.swap", &json!({"from": a, "to": b})).is_err());
|
||||
}
|
||||
@@ -27,7 +27,7 @@ use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_collab::Client;
|
||||
use figcraft_collab::message::{ClientMsg, EditingText, ServerMsg};
|
||||
use figcraft_collab::message::{ClientMsg, EditingText, LibraryReply, LibraryReq, ServerMsg};
|
||||
|
||||
pub use figcraft_collab::message::Access;
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
@@ -180,6 +180,12 @@ pub enum Event {
|
||||
},
|
||||
/// The document is on the server (`collab.publish`); joining it follows.
|
||||
Published(Published),
|
||||
/// The answer to a team library request sent in the background
|
||||
/// ([`LiveLink::library_background`]) about the file behind share link `link`.
|
||||
Library {
|
||||
link: String,
|
||||
reply: std::result::Result<LibraryReply, String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Which network interface a hosted session listens on.
|
||||
@@ -238,6 +244,21 @@ pub trait LiveLink: Send {
|
||||
fn publish(&mut self, _opts: PublishOptions) -> std::result::Result<(), String> {
|
||||
Err("publishing to a server needs the desktop app or the MCP server".into())
|
||||
}
|
||||
/// Keep `opts.doc` on a server as a new file without joining it: where, and its links
|
||||
/// (`library.publishToServer` with the server's key). Waits for the server. Errors never echo
|
||||
/// the key.
|
||||
fn upload(&mut self, _opts: PublishOptions) -> std::result::Result<Published, String> {
|
||||
Err(NO_TEAM_LIBRARIES.into())
|
||||
}
|
||||
/// One request about the team library of the file behind share link `link`; waits for the
|
||||
/// server's answer (a few seconds at most when it can't be reached). Errors never echo the
|
||||
/// link.
|
||||
fn library(&mut self, _link: &str, _req: LibraryReq) -> std::result::Result<LibraryReply, String> {
|
||||
Err(NO_TEAM_LIBRARIES.into())
|
||||
}
|
||||
/// Send library requests `(link, request)` in the background, in order; each answer arrives as
|
||||
/// [`Event::Library`]. Ignored while an earlier call's requests are still out.
|
||||
fn library_background(&mut self, _jobs: Vec<(String, LibraryReq)>) {}
|
||||
/// End the current session (stop hosting or leave).
|
||||
fn leave(&mut self);
|
||||
/// Your latest presence (the link throttles and coalesces).
|
||||
@@ -297,6 +318,11 @@ impl Live {
|
||||
self.link = Some(link);
|
||||
}
|
||||
|
||||
/// The installed networking, if any.
|
||||
pub(crate) fn link(&mut self) -> Option<&mut (dyn LiveLink + 'static)> {
|
||||
self.link.as_deref_mut()
|
||||
}
|
||||
|
||||
/// A link is installed: sessions can be joined (and hosted, when [`can_host`](Self::can_host)).
|
||||
pub fn available(&self) -> bool {
|
||||
self.link.is_some()
|
||||
@@ -401,6 +427,9 @@ pub fn editing_hint(names: &[&str]) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Why team libraries aren't available (no network link, or the browser build).
|
||||
pub const NO_TEAM_LIBRARIES: &str = "team libraries on a server need the desktop app or the MCP server";
|
||||
|
||||
/// Why an edit of a server file you joined with a view link is refused.
|
||||
pub const VIEW_ONLY: &str = "View only: the link you joined with lets you see this file, not change it";
|
||||
/// Why an edit of a file on its way to a server is refused.
|
||||
@@ -565,6 +594,7 @@ impl Session {
|
||||
self.message = Some(format!("Published {} to the server", p.name));
|
||||
live.published = Some(p);
|
||||
}
|
||||
Event::Library { link, reply } => self.library_answer(&link, reply),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//! drop an instance into the frame or auto layout slot under the pointer, or with Alt held onto an
|
||||
//! instance to swap it) and styles (click to apply to the selection), for this file and for every
|
||||
//! enabled library, with a "Library updates available" banner. The libraries view publishes this
|
||||
//! file as a library and enables library files.
|
||||
//! file as a library (to the file, or to its server for the team, with how other files use it),
|
||||
//! enables library files and team libraries (by a share link), and swaps one library for another.
|
||||
//!
|
||||
//! Thousands of assets stay smooth: only the tiles in view are laid out, and thumbnails render
|
||||
//! when their tile first shows (a few per frame), are cached, and re-render only when their
|
||||
@@ -15,6 +16,7 @@ use std::sync::Arc;
|
||||
use egui::{Align2, Color32, CornerRadius, Id, Pos2, Rect, Sense, Stroke, Ui, pos2, vec2};
|
||||
use figcraft_doc::{Document, NodeId, NodeKind, Style, StyleKind};
|
||||
use figcraft_engine::library;
|
||||
use figcraft_engine::library_server::{is_server_link, shown_path};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::FigApp;
|
||||
@@ -34,6 +36,12 @@ const THUMBS_PER_FRAME: usize = 8;
|
||||
const MAX_COMPARED: usize = 20_000;
|
||||
/// Look for changed library files this often (seconds) while the panel shows.
|
||||
const LIBRARY_REFRESH: f64 = 2.0;
|
||||
/// Ask team libraries' servers for newer versions this often (seconds) while the panel shows.
|
||||
// shortcut: polled (a few bytes when nothing changed); a server push needs a connection per
|
||||
// library, worth it once libraries are used beyond the Assets panel.
|
||||
const SERVER_REFRESH: f64 = 30.0;
|
||||
/// Components listed in a team library's usage.
|
||||
const USAGE_ROWS: usize = 8;
|
||||
const PAD: f32 = 16.0;
|
||||
|
||||
/// Where an asset comes from: this file or an enabled library (its id).
|
||||
@@ -223,6 +231,14 @@ fn refresh_libraries(app: &mut FigApp, ui: &Ui) {
|
||||
let _ = app.session.libraries.load(p);
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(id, now));
|
||||
// Team libraries: in the background, at once for one not fetched yet.
|
||||
let sid = Id::new("asset_server_libraries_checked");
|
||||
let fetched: Option<f64> = ui.data(|d| d.get_temp(sid));
|
||||
let unread = paths.iter().any(|p| is_server_link(p) && app.session.libraries.cached(p).is_none());
|
||||
if paths.iter().any(|p| is_server_link(p)) && (unread || fetched.is_none_or(|l| now - l >= SERVER_REFRESH)) {
|
||||
app.session.refresh_server_libraries();
|
||||
ui.data_mut(|d| d.insert_temp(sid, now));
|
||||
}
|
||||
}
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(LIBRARY_REFRESH));
|
||||
}
|
||||
@@ -705,9 +721,17 @@ fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) {
|
||||
let mut form: PublishForm = ui.data(|d| d.get_temp(form_id)).unwrap_or_default();
|
||||
title(ui, w, "This file");
|
||||
let m = doc.published.as_ref().map(|p| &p.manifest);
|
||||
let status = match m {
|
||||
Some(m) => format!("{} · published version {}", doc.name, m.version),
|
||||
None => format!("{} · not published", doc.name),
|
||||
// A file on a server publishes its library there (a live file doesn't keep a local one).
|
||||
let server = app.session.server_link();
|
||||
let on_server = server.as_deref().and_then(|l| match app.session.libraries.cached(l) {
|
||||
Some(Ok(lib)) => Some(lib.manifest.version),
|
||||
_ => None,
|
||||
});
|
||||
let status = match (&server, on_server, m) {
|
||||
(Some(_), Some(v), _) => format!("{} · version {v} on the server", doc.name),
|
||||
(Some(_), None, _) => format!("{} · on a server", doc.name),
|
||||
(None, _, Some(m)) => format!("{} · published version {}", doc.name, m.version),
|
||||
(None, _, None) => format!("{} · not published", doc.name),
|
||||
};
|
||||
hint(ui, &status);
|
||||
let all = library::publishable(doc);
|
||||
@@ -715,7 +739,7 @@ fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) {
|
||||
hint(ui, "Create components, styles or variables to publish them as a library.");
|
||||
return;
|
||||
}
|
||||
let def = library::default_selection(doc);
|
||||
let def = library::default_selection(doc, m);
|
||||
let mut include: Vec<Value> = Vec::new();
|
||||
let mut check = |ui: &mut Ui, key: String, default: bool, label: &str| -> bool {
|
||||
let on = form.picked.get(&key).copied().unwrap_or(default);
|
||||
@@ -767,12 +791,33 @@ fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) {
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
let any = !include.is_empty();
|
||||
let mut publish = false;
|
||||
let (mut publish, mut to_server) = (false, false);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(PAD);
|
||||
publish = crate::widgets::button(ui, if m.is_some() { "Publish changes" } else { "Publish" }, true, any).clicked();
|
||||
if server.is_some() {
|
||||
to_server = crate::widgets::button(ui, "Publish to server", true, any).clicked();
|
||||
} else {
|
||||
publish = crate::widgets::button(ui, if m.is_some() { "Publish changes" } else { "Publish" }, true, any).clicked();
|
||||
}
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
match &server {
|
||||
Some(link) => usage(app, ui, w, link),
|
||||
None if app.session.live.can_host() => {
|
||||
hint(
|
||||
ui,
|
||||
"To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there.",
|
||||
);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if to_server {
|
||||
let description = form.description.clone().unwrap_or_else(|| description.clone());
|
||||
if let Some(r) = app.run("library.publishToServer", json!({"description": description, "include": include})) {
|
||||
form = PublishForm::default();
|
||||
app.toast(format!("Published version {} to the server", r["version"].as_u64().unwrap_or(0)));
|
||||
}
|
||||
}
|
||||
if publish {
|
||||
let description = form.description.clone().unwrap_or(description);
|
||||
if let Some(r) = app.run("library.publish", json!({"description": description, "include": include})) {
|
||||
@@ -791,13 +836,47 @@ fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) {
|
||||
ui.data_mut(|d| d.insert_temp(form_id, form));
|
||||
}
|
||||
|
||||
/// How other files use this file's library on its server (asked for with a button: it is a
|
||||
/// request to the server).
|
||||
fn usage(app: &mut FigApp, ui: &mut Ui, w: f32, link: &str) {
|
||||
let usage_id = Id::new(("asset_library_usage", link));
|
||||
let mut ask = false;
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(PAD);
|
||||
ask = crate::widgets::button(ui, "Show usage", false, true).clicked();
|
||||
});
|
||||
if ask && let Some(v) = app.run("library.usage", json!({})) {
|
||||
ui.data_mut(|d| d.insert_temp(usage_id, Arc::new(v)));
|
||||
}
|
||||
let Some(v) = ui.data(|d| d.get_temp::<Arc<Value>>(usage_id)) else {
|
||||
ui.add_space(8.0);
|
||||
return;
|
||||
};
|
||||
let files = v["files"].as_array().map_or(0, Vec::len);
|
||||
let instances = v["instances"].as_u64().unwrap_or(0);
|
||||
let plural = |n: u64, one: &str, many: &str| format!("{n} {}", if n == 1 { one } else { many });
|
||||
hint(ui, &format!("Used in {} · {}", plural(files as u64, "file", "files"), plural(instances, "instance", "instances")));
|
||||
let t = Tokens::get(ui.ctx());
|
||||
for c in v["components"].as_array().into_iter().flatten().take(USAGE_ROWS) {
|
||||
let name = c["name"].as_str().unwrap_or("(no longer published)");
|
||||
let (n, f) = (c["instances"].as_u64().unwrap_or(0), c["files"].as_u64().unwrap_or(0));
|
||||
let (r, _) = ui.allocate_exact_size(vec2(w, 20.0), Sense::hover());
|
||||
let right = format!("{} in {}", plural(n, "instance", "instances"), plural(f, "file", "files"));
|
||||
let rg = ui.painter().layout_no_wrap(right, regular(10.0), t.text_secondary);
|
||||
ui.painter().galley(pos2(r.max.x - PAD - rg.size().x, r.center().y - rg.size().y / 2.0), rg.clone(), t.text_secondary);
|
||||
crate::bidi::paint_name(ui.painter(), pos2(r.min.x + PAD, r.center().y), name, regular(11.0), t.text, w - PAD * 3.0 - rg.size().x);
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
|
||||
fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
title(ui, w, "Libraries in this file");
|
||||
if doc.libraries.enabled.is_empty() {
|
||||
hint(ui, "No libraries yet. Add a .figcraft file that was published as a library.");
|
||||
hint(ui, "No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link.");
|
||||
}
|
||||
let mut remove = None;
|
||||
let (mut remove, mut swap) = (None, None);
|
||||
let swappable = doc.libraries.enabled.len() > 1;
|
||||
for l in &doc.libraries.enabled {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(w, 44.0), Sense::hover());
|
||||
let status = match app.session.libraries.cached(&l.path) {
|
||||
@@ -810,15 +889,16 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) {
|
||||
}
|
||||
Some(Ok(_)) => ("The file now holds another library".into(), t.danger),
|
||||
Some(Err(e)) => (e.to_string(), t.danger),
|
||||
None if is_server_link(&l.path) => ("Fetching from the server…".into(), t.text_secondary),
|
||||
None => ("Not read yet".into(), t.text_secondary),
|
||||
};
|
||||
let text_w = w - PAD - 44.0;
|
||||
let text_w = w - PAD - if swappable { 72.0 } else { 44.0 };
|
||||
let name = ui.painter().layout_no_wrap(l.name.clone(), semibold(11.0), t.text);
|
||||
let line = ui.painter().layout(status.0, regular(10.0), status.1, text_w);
|
||||
let clip = Rect::from_min_size(pos2(r.min.x + PAD, r.min.y), vec2(text_w, r.height()));
|
||||
ui.painter().with_clip_rect(clip).galley(pos2(r.min.x + PAD, r.min.y + 6.0), name, t.text);
|
||||
ui.painter().with_clip_rect(clip).galley(pos2(r.min.x + PAD, r.min.y + 23.0), line, t.text_secondary);
|
||||
ui.interact(clip, Id::new(("asset_lib_row", &l.id)), Sense::hover()).on_hover_text(&l.path);
|
||||
ui.interact(clip, Id::new(("asset_lib_row", &l.id)), Sense::hover()).on_hover_text(shown_path(&l.path));
|
||||
let x = Rect::from_center_size(pos2(r.max.x - 24.0, r.center().y), vec2(24.0, 24.0));
|
||||
let xr = ui.interact(x, Id::new(("asset_lib_remove", &l.id)), Sense::click()).tip("Remove library from this file");
|
||||
if xr.hovered() {
|
||||
@@ -828,6 +908,24 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) {
|
||||
if xr.clicked() {
|
||||
remove = Some(l.id.clone());
|
||||
}
|
||||
if swappable {
|
||||
let sw = Rect::from_center_size(pos2(r.max.x - 52.0, r.center().y), vec2(24.0, 24.0));
|
||||
let sr = ui
|
||||
.interact(sw, Id::new(("asset_lib_swap", &l.id)), Sense::click())
|
||||
.tip("Swap library: use another library's assets of the same names");
|
||||
if sr.hovered() {
|
||||
ui.painter().rect_filled(sw, CornerRadius::same(5), t.hover);
|
||||
}
|
||||
icons::paint(ui.painter(), sw.shrink(6.0), Icon::Swap, t.icon);
|
||||
crate::right::menu_popup(&sr).show(|ui| {
|
||||
for other in doc.libraries.enabled.iter().filter(|o| o.id != l.id) {
|
||||
if crate::right::menu_item(ui, &format!("Swap to {}", other.name), "") {
|
||||
swap = Some((l.id.clone(), other.id.clone()));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
let mut add = false;
|
||||
@@ -835,9 +933,30 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) {
|
||||
ui.add_space(PAD);
|
||||
add = crate::widgets::button(ui, "Add library file…", false, true).clicked();
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(PAD);
|
||||
let pasted = crate::widgets::text_field(ui, Id::new("asset_add_link"), "", w - PAD * 2.0, "Paste a team library link (a view link)");
|
||||
if let Some(link) = pasted.map(|l| l.trim().to_string()).filter(|l| !l.is_empty())
|
||||
&& let Some(r) = app.run("library.add", json!({"path": link}))
|
||||
{
|
||||
app.toast(format!("Added the library \"{}\"", r["name"].as_str().unwrap_or_default()));
|
||||
}
|
||||
});
|
||||
if let Some(id) = remove {
|
||||
app.run("library.remove", json!({"id": id}));
|
||||
}
|
||||
if let Some((from, to)) = swap
|
||||
&& let Some(r) = app.run("library.swap", json!({"from": from, "to": to}))
|
||||
{
|
||||
let moved = ["components", "styles", "variables"].iter().map(|k| r[*k].as_u64().unwrap_or(0)).sum::<u64>();
|
||||
let missed = r["unmatched"].as_array().map_or(0, Vec::len);
|
||||
app.toast(if missed == 0 {
|
||||
format!("Swapped {moved} asset{}", if moved == 1 { "" } else { "s" })
|
||||
} else {
|
||||
format!("Swapped {moved} asset{}; {missed} had no match and still use the old library", if moved == 1 { "" } else { "s" })
|
||||
});
|
||||
}
|
||||
if add && let Some(p) = app.services.pick_open("Add library", &["figcraft"]) {
|
||||
let path = p.to_string_lossy().to_string();
|
||||
if let Some(r) = app.run("library.add", json!({"path": path})) {
|
||||
|
||||
+32
-2
@@ -13,7 +13,7 @@ modes and comments created in a session take ids from their creator's id space,
|
||||
carets show on the canvas. **Step 5 is implemented**: the
|
||||
standalone `figcraft-server` (`apps/figcraft-server`, running it: [`server.md`](server.md)) keeps
|
||||
many files with persistence and compaction, and share links that let people edit or only view;
|
||||
the app publishes to it. The rest is the plan the next implementation PRs follow and update;
|
||||
the app publishes to it, and a file's team library lives there for other files to use. The rest is the plan the next implementation PRs follow and update;
|
||||
sections say what is built.
|
||||
|
||||
Goal: several people edit the same `.figcraft` file at the same time and see each other's edits,
|
||||
@@ -157,7 +157,9 @@ As built:
|
||||
|
||||
*Built* (`figcraft_collab::message`): the types below and both encodings. Protocol version 2 (the
|
||||
`Hello`'s `protocol`): the welcome says what the client may do, and a connection can manage a
|
||||
server's files instead of joining one; a client of another version is refused with the reason.
|
||||
server's files or use a file's team library instead of joining one; a client of another version is
|
||||
refused with the reason. (The library messages came later within version 2: a server from before
|
||||
them can't read them and refuses the connection.)
|
||||
|
||||
**Encoding (decided):** a binary message is a 1-byte wire version followed by **postcard** (serde),
|
||||
which is compact and needs no schema files. Property values inside are compact JSON text: the
|
||||
@@ -180,6 +182,8 @@ Client → server:
|
||||
| `Ping` | Keep-alive. |
|
||||
| `Manage {protocol, key, client_version}` | Instead of `Hello`: manage a server's files with its key. |
|
||||
| `Admin {publish {name, snapshot} \| files \| links {file} \| createLink {file, access, label} \| revoke {file, link} \| remove {file}}` | A request on a managing connection; each gets one `Admin` reply, in order. |
|
||||
| `OpenLibrary {protocol, file, token, client_version}` | Instead of `Hello`: use a file's team library with one of the file's share links. |
|
||||
| `Library {fetch {have?, usage?} \| publish {archive} \| usage}` | A request on a library connection; each gets one `Library` reply, in order. `usage` is `{file, components: [(id, instances)]}`. |
|
||||
|
||||
Server → client:
|
||||
|
||||
@@ -192,6 +196,7 @@ Server → client:
|
||||
| `Refused {reason}` | The connection is refused (protocol version, token, session full, a message out of place). |
|
||||
| `Pong` | Keep-alive. |
|
||||
| `Admin {ready \| published {file, edit, view} \| files \| links \| link \| done \| failed {reason}}` | A managing connection's answer: the key is right, a published file with its first two links (tokens shown once), listings, a new link, done, or why not. |
|
||||
| `Library {ready {access} \| library {version, archive} \| unchanged {version} \| empty \| published {version} \| usage [{usage, updated}] \| failed {reason}}` | A library connection's answer: the link is right (with its access), the library (a `.figcraft` archive holding it), no newer version, no library yet, kept, the usage reports, or why not. |
|
||||
|
||||
`Change` is one of: `Create {id, type, props}`, `Set {id, prop, value}`, `Delete {id}` (deleting a
|
||||
node deletes its subtree; ids are never allocated twice), plus the document-level `SetDoc`, `Put`
|
||||
@@ -485,6 +490,31 @@ resuming with its offline edits, and leaving the session going back to snapshot
|
||||
same clients (the session id, client ids and log come back with the file), sending what they
|
||||
made meanwhile. The app's session ending (code 1001) stays final.
|
||||
|
||||
*Built* (team libraries on the server; using them: [`server.md`](server.md#team-libraries)):
|
||||
|
||||
- **A library connection.** A connection that opens with `OpenLibrary {file, token}` (checked like a
|
||||
hello's token, so any share link of the file opens it with that link's access) sends `Library`
|
||||
requests: fetch the file's library unless the client has that version (`have`), publish a new
|
||||
version (edit links only), or read the usage reports. Requests are one-shot: the app opens a
|
||||
connection, asks and closes it.
|
||||
- **What is kept.** The published library a library file holds (manifest and snapshot with
|
||||
per-asset versions) travels as a small `.figcraft` archive holding only it. The server reads it
|
||||
in full with the limits a library file is read with, requires the file's library id once one is
|
||||
kept and a newer version, then writes it beside the file (`library.bin`, atomically) and records
|
||||
id, name and version in the file's description. Publishing from the app first fetches the
|
||||
server's version and publishes the next one from it, so per-asset versions continue whoever
|
||||
published last; a race is refused and the next publish follows.
|
||||
- **Using it.** A file enables the library with the link in place of a library file's path
|
||||
(`LibraryLink::path`), so placing, styles, variables and updates work as for library files. The
|
||||
engine never opens a socket: commands ask the app's network link and wait (a few seconds at most
|
||||
when the server can't be reached); the Assets panel asks in the background (`have` set, so an
|
||||
unchanged library costs a few bytes) when it opens and every 30 seconds, and the answers arrive
|
||||
like other session events. The last library fetched stays in use while the server can't be
|
||||
reached.
|
||||
- **Usage.** Each fetch carries the asking file's name and its instances of each library component;
|
||||
the server keeps the latest report per file name (`usage.json`, rewritten only when it changes,
|
||||
capped) and answers `usage` with them. `library.usage` adds them up with the components' names.
|
||||
|
||||
*Built* (opening invite links):
|
||||
|
||||
- The desktop app takes an invite link on its command line (`figcraft "figcraft://join?…"`): once
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Collaboration parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version
|
||||
history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model,
|
||||
@@ -19,13 +19,14 @@ cursors, selections and text carets shown live ([`collab-protocol.md`](collab-pr
|
||||
| Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences | (shared once multiplayer lands) |
|
||||
| Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file | — |
|
||||
| Branching and merging | yes (paid plans) | **done, local**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived), update from main, archive. Left: a branch's own version history, list properties (fills, text runs) merged item by item, branches in live sessions | 1–3 |
|
||||
| Team / organization libraries | yes | local library files only ([components-parity](components-parity.md)) | 10–20 |
|
||||
| Team / organization libraries | yes | **partial**: a file's library is published to `figcraft-server` under the file and enabled in other files by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names ([components-parity](components-parity.md), [server.md](server.md#team-libraries)). No team-wide library list, accounts or analytics over time | 4–8 |
|
||||
| Notifications, activity | yes | no | with sharing |
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Team libraries on `figcraft-server` (gap 10): a file's library is published to the server under the file and enabled in other files by a share link, updates are noticed in the background and accepted with overrides kept, the server counts which files use which components, and a library can be swapped for another by names; team libraries 10–20 h → 4–8 h; ready ~12% → ~13% |
|
||||
| 2026-10-11 | minor | Branches landed (gap 17): File ▸ Create branch… copies the file into a branch tab that remembers its base; Review and merge shows both sides' changes (layers with Inspect-style property changes, styles, variables, comments) and the conflicts (the same property changed on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merging is one undo step and archives the branch; Update from main; branches are saved in the `.figcraft` file. Comments, versions, find, misc breadth 80% → 85%, ready 58% → 60%, 4–8 h → 2–5 h; collaboration 12% → 13%; menu breadth 204/204 (the catalog gained File ▸ Create branch…); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~378–722 h |
|
||||
| 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure |
|
||||
| 2026-10-11 | minor | Collaboration wording after the collaboration polish: styles, variables, collections, modes and comments created in a live session take ids from their creator's id space (concurrent creations no longer overwrite each other; the server checks it), others' text carets and selections show on the canvas, and typing in a layer someone else is typing in says so; percentages unchanged |
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Components, variants, styles and variables parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Design systems are why teams adopt Figma. **Components: breadth ~95%, ready ~68%. Styles and
|
||||
variables: breadth ~92%, ready ~72% (estimated).** QA'd with a synthetic design system of ~300
|
||||
components used 2,000 times (`crates/engine/src/synthetic_ds.rs`, `components_scale_tests.rs`);
|
||||
not with real libraries. Remaining: 7–14 h here, plus shared libraries (10–20 h after
|
||||
multiplayer).
|
||||
not with real libraries. Remaining: 7–14 h here, plus team libraries' tail (4–8 h, partly after
|
||||
accounts).
|
||||
|
||||
## Components and variants
|
||||
|
||||
@@ -32,7 +32,9 @@ multiplayer).
|
||||
| Publish a file's components, sets, styles and variables (per-asset versions) | done | as local library files |
|
||||
| Enable a library in a file; read-only linked copies | done | |
|
||||
| Library updates available; overrides kept | done | |
|
||||
| Shared team/organization libraries, library analytics, swap library | missing | [gaps](gaps.md) #10 |
|
||||
| Team libraries: publish to a `figcraft-server` under the file, enable by a share link (a view link reads it), background update checks | done | [server.md](server.md#team-libraries); no team-wide library list or accounts ([gaps](gaps.md) #10) |
|
||||
| Library analytics | partial | instances per component and the files using them, as apps report them; nothing over time, no detach counts |
|
||||
| Swap library (instances, styles and variables to another library's of the same names, overrides kept) | done | what has no match stays and is listed; one undo step |
|
||||
|
||||
## Styles and variables
|
||||
|
||||
@@ -51,5 +53,6 @@ multiplayer).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Team libraries on `figcraft-server` (publish under the file, enable by a share link, updates with overrides kept), usage counts per component and file, and swapping a library by names (gap 10) |
|
||||
| 2026-10-11 | minor | Extended collections, variables import/export (JSON, CSS) and gradient stop and variant property bindings landed: styles and variables breadth ~85% → ~92%, ready ~70% → ~72%; remaining here 10–20 h → 7–14 h |
|
||||
| 2026-10-10 | major | Created from the 2026-10-10 re-measure |
|
||||
|
||||
+14
-9
@@ -1,6 +1,6 @@
|
||||
# Where FigCraft falls short of Figma
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from
|
||||
doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The
|
||||
@@ -23,7 +23,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
||||
| 7 | CPU canvas where Figma uses the GPU | Hardware/performance | 30–60 | [hardware-parity](hardware-parity.md) |
|
||||
| 8 | Platform QA is mostly macOS | Stability/platforms | 10–20 | [hardware-parity](hardware-parity.md) |
|
||||
| 9 | Figma Draw: brushes, pressure, text on a path, pattern fills | Feature | 10–18 | [target-app-parity](target-app-parity.md) |
|
||||
| 10 | Shared (team) libraries | Feature | 10–20 | [components-parity](components-parity.md) |
|
||||
| 10 | Team libraries: no team-wide lists or accounts; analytics are counts | Feature | 4–8 | [components-parity](components-parity.md) |
|
||||
| 11 | Spell check | Feature | 4–8 | [target-app-parity](target-app-parity.md) |
|
||||
| 12 | Video fills; HEIC images | File format | 5–10 | [file-format-parity](file-format-parity.md) |
|
||||
| 13 | Boolean groups hit-tested by their operands (the rest of the vector tail landed) | Feature | 1–3 | [target-app-parity](target-app-parity.md) |
|
||||
@@ -145,13 +145,17 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
||||
- **Impact:** illustration-flavoured work moved into Figma Draw in 2025.
|
||||
- **Estimate:** 10–18 h.
|
||||
|
||||
### 10. Shared (team) libraries
|
||||
- **Missing:** libraries shared across people and files without copying them around; library
|
||||
analytics; swapping a whole library.
|
||||
- **Evidence:** local library files work (publish, enable, updates with overrides kept), see
|
||||
[`components-parity.md`](components-parity.md).
|
||||
- **Impact:** design-system teams. Builds on multiplayer's server.
|
||||
- **Estimate:** 10–20 h after gap 2.
|
||||
### 10. Team libraries: no team-wide lists or accounts; analytics are counts
|
||||
- **Missing:** libraries listed for a whole team or organization (each file enables a library by
|
||||
its link), accounts and per-person library permissions, analytics beyond instance counts per
|
||||
component and file (insertions and detaches over time), update notices pushed by the server
|
||||
(apps ask every 30 seconds while the Assets panel shows).
|
||||
- **Evidence:** a file's library is published to `figcraft-server` under the file and enabled in
|
||||
other files by a share link, with per-asset versions, updates with overrides kept, usage counts
|
||||
and swapping a whole library by names ([`server.md`](server.md#team-libraries),
|
||||
[`components-parity.md`](components-parity.md)).
|
||||
- **Impact:** design-system teams.
|
||||
- **Estimate:** 4–8 h, the accounts part after gap 2.
|
||||
|
||||
### 11. Spell check
|
||||
- **Missing:** spelling underline and suggestions while editing text.
|
||||
@@ -254,6 +258,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Team libraries on `figcraft-server` landed (gap 10): a file's library is published to the server under the file and enabled in other files by a share link (per-asset versions; updates noticed in the background and accepted with overrides kept), the server counts which files use which components, and a whole library can be swapped for another by names; Team libraries breadth 40% → 70%, ready 25% → 40%, 10–20 h → 4–8 h; collaboration 12% → 13%, 55–110 h → 49–98 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–715 h |
|
||||
| 2026-10-11 | minor | Branches landed (gap 17): File ▸ Create branch… copies the file into a branch tab that remembers its base; Review and merge shows both sides' changes (layers with Inspect-style property changes, styles, variables, comments) and the conflicts (the same property changed on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merging is one undo step and archives the branch; Update from main; branches are saved in the `.figcraft` file. Comments, versions, find, misc breadth 80% → 85%, ready 58% → 60%, 4–8 h → 2–5 h; collaboration 12% → 13%; menu breadth 204/204 (the catalog gained File ▸ Create branch…); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~378–722 h |
|
||||
| 2026-10-11 | minor | Animated GIF fills and TIFF images (gap 12, without video and HEIC): GIF fills keep the original file, show the first frame on the canvas and in exports and play in the presenter and the preview (frames decoded on first play, at most 500 frames and 128 MB; always looping; the window wakes only for a GIF's next frame); TIFF images open, import, place and paste. Paint breadth 85% → 87%, 5–9 h → 4–7 h; gap 12 6–12 h → 5–10 h (video playback and HEIC left, both needing non-Rust decoders); feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
|
||||
| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
|
||||
|
||||
+31
-3
@@ -294,9 +294,9 @@ flow with its description.
|
||||
|
||||
### Local libraries
|
||||
|
||||
A `.figcraft` file can be published as a library and used from other files (local files only: no
|
||||
accounts, nothing on the network; the browser build has no file system, so there the commands that
|
||||
read library files return an error).
|
||||
A `.figcraft` file can be published as a library and used from other files (library files here;
|
||||
libraries kept on a `figcraft-server` for a team: [Team libraries](#team-libraries); the browser
|
||||
build has no file system, so there the commands that read library files return an error).
|
||||
|
||||
- **Publish** (in the library file): `library.publish {description?, include?, exclude?}` publishes
|
||||
the file's components, component sets, styles and variable collections as a new version (by
|
||||
@@ -350,6 +350,34 @@ name and sets them this way);
|
||||
library is enabled when the other file is saved), and other mains stay behind: the instance shows
|
||||
a missing main (`component.restoreMain` makes it a main here).
|
||||
|
||||
### Team libraries
|
||||
|
||||
A file kept on a `figcraft-server` ([`server.md`](server.md#team-libraries)) can hold a library that
|
||||
other files use through one of its share links. Each call waits for the server (a few seconds at
|
||||
most when it can't be reached).
|
||||
|
||||
- `library.publishToServer {server?, key?, description?, include?, exclude?}` publishes the active
|
||||
document's library as the next version of the one the server keeps under the file (selection and
|
||||
per-asset versions as `library.publish`, following the server's version): `server` is an edit
|
||||
link of the file, or omit it while the document is in a live session on a server with edit
|
||||
access; with the server's address and key (`ws://host:47900/?key=…`, or `key`) the document is
|
||||
kept there as a new file first and the result has its `file`, `edit` and `view` links. A view
|
||||
link is refused. The document itself doesn't change (nothing to undo).
|
||||
- `library.add {path: <share link>}` enables it (a view link is enough); then `library.place`,
|
||||
`library.applyStyle`, `library.bindVariable`… work as for library files. `library.list` and
|
||||
`library.updates` ask the server for a newer version first (`"server": true` in the listing);
|
||||
`library.acceptUpdates` takes it, overrides kept.
|
||||
- `library.usage {library?}` → `{id, name, instances, files: [{name, updated, instances}],
|
||||
components: [{id, name, instances, files}]}`: what the files using it reported (each fetch
|
||||
reports the asking document's name and its instances per component). `library` is an enabled
|
||||
team library; omit it for the active document's own library on its server.
|
||||
- `library.swap {from, to}` (any two enabled libraries, files or team) re-points every instance,
|
||||
style and variable copied from `from` to the published asset of the same name in `to` (variants
|
||||
by name in their set, styles by name and type, variables by collection, name and type, modes by
|
||||
name), keeping overrides; one undo step → `{components, styles, variables, unmatched: [names
|
||||
still from `from`, including this file's collections that extend one of its collections],
|
||||
removed (nothing of `from` was left, so it was removed)}`.
|
||||
|
||||
### Swapping instances
|
||||
|
||||
- `instance.swap {ids?, component}` shows another component in the instances (overrides on layers
|
||||
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
# FigCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
|
||||
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
|
||||
@@ -55,14 +55,15 @@ blockers.
|
||||
| M6 Interop & Dev Mode | ~55% | `.fig` import, video/HEIC (animated GIFs and TIFF landed 2026-10-11), CMYK intent, Code Connect | 40–80 |
|
||||
| M7 Release & fidelity (signed builds, look & feel, shortcuts) | builds done; fidelity pass started | tool-by-tool pass | 40–60 |
|
||||
| M8 Hardening & performance (bench, retained canvas, accessibility, idle) | ~60% | real-file corpus, platform QA, GPU canvas, screen readers | 60–110 |
|
||||
| M9 Real-time collaboration | core done (#103: ids, change sets, server ordering, convergence); live sessions (WebSocket transport with limits and reconnect, hosting from the app, invite links, document sync between apps, cursors, selections, avatars, follow, `collab.*` commands); per-user undo, rejection handling, offline queue and resuming after a reconnect; styles, variables, collections, modes and comments created in a session get ids from their creator's space; others' text carets and selections on the canvas; the browser client (joins desktop-hosted sessions); `figcraft://` links open the app and join (Windows registers per user; macOS and Linux packages declare the scheme); the standalone `figcraft-server` (persistence with compaction, restarts that lose nothing confirmed, publishing from the app, share links to edit or view, revocable) | accounts, teams, TLS in the desktop app, packaging the server | 45–90 |
|
||||
| M9 Real-time collaboration | core done (#103: ids, change sets, server ordering, convergence); live sessions (WebSocket transport with limits and reconnect, hosting from the app, invite links, document sync between apps, cursors, selections, avatars, follow, `collab.*` commands); per-user undo, rejection handling, offline queue and resuming after a reconnect; styles, variables, collections, modes and comments created in a session get ids from their creator's space; others' text carets and selections on the canvas; the browser client (joins desktop-hosted sessions); `figcraft://` links open the app and join (Windows registers per user; macOS and Linux packages declare the scheme); the standalone `figcraft-server` (persistence with compaction, restarts that lose nothing confirmed, publishing from the app, share links to edit or view, revocable); team libraries kept on the server and enabled by a share link, with usage counts, and swapping a library | accounts, teams, TLS in the desktop app, packaging the server | 45–90 |
|
||||
| M10 Localization (new) | not started | layer + 11 languages | 60–100 |
|
||||
| M11 Plugins | runtime, API, menu, manager and SDK done (#101) | run-time plugin UI, settings storage, distribution, widgets; Figma plugin compatibility is an owner decision | 25–55 |
|
||||
| M12 AI features (new, owner decision) | not started | local models | 30–60 |
|
||||
|
||||
## Next, after the current focus
|
||||
|
||||
- Shared libraries on top of the multiplayer server (10–20 h), branching (8–15 h).
|
||||
- Team libraries for a whole team (lists, accounts and permissions) and deeper library analytics (4–8 h, after
|
||||
accounts), branching (8–15 h).
|
||||
- GPU canvas held to the CPU renderer (30–60 h).
|
||||
- Figma Draw tools (10–18 h).
|
||||
- Mobile viewer and prototype mirror (20–40 h).
|
||||
@@ -71,6 +72,7 @@ blockers.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Team libraries on `figcraft-server` landed (gap 10): a file's library is published to the server under the file and enabled in other files by a share link (per-asset versions; updates noticed in the background and accepted with overrides kept), the server counts which files use which components, and a whole library can be swapped for another by names; Team libraries breadth 40% → 70%, ready 25% → 40%, 10–20 h → 4–8 h; collaboration 12% → 13%, 55–110 h → 49–98 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–715 h |
|
||||
| 2026-10-11 | minor | Branches landed (gap 17): File ▸ Create branch… copies the file into a branch tab that remembers its base; Review and merge shows both sides' changes (layers with Inspect-style property changes, styles, variables, comments) and the conflicts (the same property changed on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merging is one undo step and archives the branch; Update from main; branches are saved in the `.figcraft` file. Comments, versions, find, misc breadth 80% → 85%, ready 58% → 60%, 4–8 h → 2–5 h; collaboration 12% → 13%; menu breadth 204/204 (the catalog gained File ▸ Create branch…); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~378–722 h |
|
||||
| 2026-10-11 | minor | Animated GIF fills and TIFF images (gap 12, without video and HEIC): GIF fills keep the original file, show the first frame on the canvas and in exports and play in the presenter and the preview (frames decoded on first play, at most 500 frames and 128 MB; always looping; the window wakes only for a GIF's next frame); TIFF images open, import, place and paste. Paint breadth 85% → 87%, 5–9 h → 4–7 h; gap 12 6–12 h → 5–10 h (video playback and HEIC left, both needing non-Rust decoders); feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
|
||||
| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
|
||||
|
||||
+40
-2
@@ -7,7 +7,9 @@ restart (or a crash) loses nothing that was confirmed, and apps reconnect where
|
||||
headless, native only (no web build), and runs on the same platforms as the desktop app.
|
||||
|
||||
There are no user accounts yet: a share link is the access to one file, and the **server key**
|
||||
manages files (publishing, links, removal). Accounts and per-person permissions come later.
|
||||
manages files (publishing, links, removal). Accounts and per-person permissions come later. A file
|
||||
can also hold a **team library** that other files use through one of its links (see
|
||||
[Team libraries](#team-libraries)).
|
||||
|
||||
The protocol and the design behind it are in [`collab-protocol.md`](collab-protocol.md).
|
||||
|
||||
@@ -61,6 +63,35 @@ others see your cursor and selection, but you can't change anything: the toolbar
|
||||
only** badge with only the Move and Hand tools, edits are refused with a message (and the server
|
||||
refuses them too). **Leave session** keeps the file open as your own copy, editable again.
|
||||
|
||||
## Team libraries
|
||||
|
||||
A file on the server can hold a **team library**: its published components, styles and variables,
|
||||
which other files use without anyone copying the library file around.
|
||||
|
||||
- **Publishing.** In the library file, opened from the server with an edit link (or published to
|
||||
it), **Assets ▸ Libraries ▸ Publish to server** publishes the chosen assets as the library's next
|
||||
version (`library.publishToServer`; with the server's address and key a file not on the server
|
||||
yet is kept there first). The server keeps the latest version of each file's library. Each asset
|
||||
keeps the version it last changed in, so files using the library update only what changed; the
|
||||
server's version is the one that counts, so when someone published since, the next publish
|
||||
follows it. Publishing needs an edit link: a view link's publish is refused.
|
||||
- **Using.** In another file, paste one of the library file's links (a **view link** is enough)
|
||||
into **Assets ▸ Libraries** (`library.add {path: <link>}`). The library is listed in the Assets
|
||||
panel like a library file: placing a component or applying a style copies it in as a read-only
|
||||
linked copy, as for library files. While the Assets panel shows, the app asks the server for a
|
||||
newer version at once and then every 30 seconds (a few bytes when nothing changed) and shows
|
||||
**Library updates available**; accepting keeps instance overrides. While the server can't be
|
||||
reached, the last version fetched stays in use. The file keeps the link, token included:
|
||||
sharing that file shares read access to the library.
|
||||
- **Usage.** Each time a file's app asks for the library it reports which of its components the
|
||||
file uses and how often. **Show usage** in the library file (`library.usage`) lists the files and
|
||||
the instances of each component. Files are told apart by their names; a file's report changes
|
||||
only when its usage does.
|
||||
- **Swapping.** `library.swap` (the swap button by an enabled library) moves a file's instances,
|
||||
styles and variables from one library to another's of the same names, keeping overrides.
|
||||
|
||||
The browser build can't use team libraries yet (it has no library support of its own).
|
||||
|
||||
## Managing files and links
|
||||
|
||||
| Command | What it does |
|
||||
@@ -109,6 +140,8 @@ network address.
|
||||
<data>/files/<id>/links.json its links: id, access, label, created, SHA-256 of the token
|
||||
<data>/files/<id>/snapshot.bin its state at the last compaction (with a checksum)
|
||||
<data>/files/<id>/log.bin what changed since, one checksummed record per change
|
||||
<data>/files/<id>/library.bin its team library, when one was published (a .figcraft archive)
|
||||
<data>/files/<id>/usage.json which files use that library's components, as their apps report it
|
||||
```
|
||||
|
||||
- **Writes are atomic**: a snapshot, a description or a link list is written to a temporary file
|
||||
@@ -170,5 +203,10 @@ internet), and use `wss://` links from the browser build.
|
||||
- **Limits**: the transport's (message sizes, 100 messages a second per connection, idle and
|
||||
handshake timeouts, `max_connections`) and the collaboration core's (layers, changes per batch,
|
||||
`max_people`), plus `max_files`, `max_file_mb` and `max_links`. Every message is decoded
|
||||
defensively, and fuzzed in the tests.
|
||||
defensively, and fuzzed in the tests. A library is read in full before it is kept (as files
|
||||
using it read it: size limits on every part, at most `max_file_mb`), must be the file's library
|
||||
at a newer version, and is checked on the server's main thread; usage reports keep at most 2,000
|
||||
components per file and 500 files per library.
|
||||
- **Team libraries**: any link of a file reads its library and its usage reports (file names and
|
||||
counts); only an edit link publishes it.
|
||||
- **Not there yet**: user accounts, per-person permissions, rate limits per address, audit logs.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# FigCraft parity with Figma
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
|
||||
|
||||
The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md)
|
||||
summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is
|
||||
@@ -35,7 +35,7 @@ tool; presence was being graded as behaviour in places.
|
||||
|
||||
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|
||||
|---|---|---|---|
|
||||
| Full target (ready for real work) | ~47% | ~365–690 h | multiplayer (45–90), localization (60–100), plugins (25–55), the interaction pass (40–60), `.fig` import (30–60), GPU canvas (30–60), AI (30–60) |
|
||||
| Full target (ready for real work) | ~47% | ~360–680 h | multiplayer (45–90), localization (60–100), plugins (25–55), the interaction pass (40–60), `.fig` import (30–60), GPU canvas (30–60), AI (30–60) |
|
||||
| Mainstream practitioner | ~43% | ~170–315 h | core editor depth incl. `.fig` import (88–171), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) |
|
||||
| Essentials user | ~53% | ~85–160 h | `.fig` import so files people send open (30–60), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) |
|
||||
|
||||
@@ -112,7 +112,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
|
||||
| 16 | Comments, versions, find, misc | 3 | 85% | 60% | Comments are local only; a branch's own version history (branches with review and merge landed) | 2–5 |
|
||||
| 17 | Multiplayer & sharing | 8 | 15% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view. Left: accounts and teams, cursor chat, TLS in the desktop app ([collaboration-parity](collaboration-parity.md)) | 45–90 |
|
||||
| 18 | Plugins & widgets | 4 | 35% | 20% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK, 2 examples). Left: plugin UI at run time, settings storage, distribution, widgets; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 25–55 |
|
||||
| 19 | Team libraries & organization | 2 | 40% | 25% | Local library files exist; no shared/cloud library, no library analytics | 10–20 |
|
||||
| 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4–8 |
|
||||
| 20 | Figma Draw extras | 2 | 45% | 30% | Have: variable width, lasso, shape builder, noise/texture. Missing: brushes (stretch/scatter), pressure, text on a path, pattern fills, multi-edit | 10–18 |
|
||||
| 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 |
|
||||
| | **Weighted (÷113)** | 113 | **~79%** | **~57%** | | **206–411** |
|
||||
@@ -577,6 +577,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Team libraries on `figcraft-server` landed (gap 10): a file's library is published to the server under the file and enabled in other files by a share link (per-asset versions; updates noticed in the background and accepted with overrides kept), the server counts which files use which components, and a whole library can be swapped for another by names; Team libraries breadth 40% → 70%, ready 25% → 40%, 10–20 h → 4–8 h; collaboration 12% → 13%, 55–110 h → 49–98 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–715 h |
|
||||
| 2026-10-11 | minor | Branches landed (gap 17): File ▸ Create branch… copies the file into a branch tab that remembers its base; Review and merge shows both sides' changes (layers with Inspect-style property changes, styles, variables, comments) and the conflicts (the same property changed on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merging is one undo step and archives the branch; Update from main; branches are saved in the `.figcraft` file. Comments, versions, find, misc breadth 80% → 85%, ready 58% → 60%, 4–8 h → 2–5 h; collaboration 12% → 13%; menu breadth 204/204 (the catalog gained File ▸ Create branch…); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~378–722 h |
|
||||
| 2026-10-11 | minor | Animated GIF fills and TIFF images (gap 12, without video and HEIC): GIF fills keep the original file, show the first frame on the canvas and in exports and play in the presenter and the preview (frames decoded on first play, at most 500 frames and 128 MB; always looping; the window wakes only for a GIF's next frame); TIFF images open, import, place and paste. Paint breadth 85% → 87%, 5–9 h → 4–7 h; gap 12 6–12 h → 5–10 h (video playback and HEIC left, both needing non-Rust decoders); feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
|
||||
| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
|
||||
|
||||
Reference in New Issue
Block a user