M9: share with a join code: stop sharing keeps the file

Online, the file is its owner's: others get links and codes of their own, and
Stop sharing (collab.stop) revokes those on the server. Whoever joined with
them is disconnected at once and told sharing was stopped (the session's end,
with their copy); the owner stays in the file, which stays on the server, and
Start sharing gives the same file new links and codes. Removing the file is a
separate, confirmed step under Advanced.

The join field is the shared text input: as tall as the Join button beside it.
This commit is contained in:
bflatastic authored and BFlat committed 2026-10-12 19:42:28 +07:00
1 parent 6720ea4a21
commit 8f6a123f29
21 files changed
+745 -231

No files matched your search

+1 -1
View File
@@ -276,7 +276,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Sharing with a join code (M9, from the owner's use: sharing wasn't intuitive): the Share popover is one flow, **Online** (the file is published to the server in Preferences, built in `wss://collab.craftapps.dev/`, and the server names its edit and view links with nine-symbol codes like `K7M-2QX-9TD`) or **Local network** (the app hosts; a 20-symbol code carries its IPv4 address, port and a 44-bit secret, with check bits), one Start sharing button, the code large with Copy code and Copy link, who is here, can edit / can view online, and Stop sharing (online: the file leaves the server); **Join with a code** is one field (popover, home screen, Join dialog) for a code of either kind or a link; addresses, keys and ports moved under Advanced and Preferences ▸ Server for online sharing (reset, or empty to turn online sharing off). Protocol additions within version 2 (`Resolve` / `Resolved`, `Admin code`): the server keeps a stretched hash and the wrapped token, never the code, and limits wrong codes (10 a minute per address, a 15-minute lockout after 10 wrong, 1,000 wrong an hour for everyone); codes go with their link and file. Older servers leave the links in place of a code; older apps join by link. `collab.share`, `collab.join {code}`, `code` and `share` in `collab.peers`; the browser joins by code. Numbers unchanged |
| 2026-10-12 | minor | Sharing with a join code (M9, from the owner's use: sharing wasn't intuitive): the Share popover is one flow, **Online** (the file is published to the server in Preferences, built in `wss://collab.craftapps.dev/`, and the server names its edit and view links with nine-symbol codes like `K7M-2QX-9TD`) or **Local network** (the app hosts; a 20-symbol code carries its IPv4 address, port and a 44-bit secret, with check bits), one Start sharing button, the code large with Copy code and Copy link, who is here, can edit / can view online, and Stop sharing (online: the others' links and codes are revoked and they are disconnected, while the file stays on the server with its owner in it and can be shared again with new codes; removing it is a separate, confirmed step); **Join with a code** is one field (popover, home screen, Join dialog) for a code of either kind or a link; addresses, keys and ports moved under Advanced and Preferences ▸ Server for online sharing (reset, or empty to turn online sharing off). Protocol additions within version 2 (`Resolve` / `Resolved`, `Admin code`): the server keeps a stretched hash and the wrapped token, never the code, and limits wrong codes (10 a minute per address, a 15-minute lockout after 10 wrong, 1,000 wrong an hour for everyone); codes go with their link and file. Older servers leave the links in place of a code; older apps join by link. `collab.share`, `collab.join {code}`, `code` and `share` in `collab.peers`; the browser joins by code. Numbers unchanged |
| 2026-10-12 | minor | Live sessions per document (M9, a fix from real use): the engine keeps one session per open document instead of one for the app, the link carries several under their own ids (desktop, browser, tests), the window and the `collab.*` commands follow the active document, `collab.peers` gains `session` and `sessions`, `ui.inspect`'s `live` gains `session` and `sessions`; joining opens the session's file in its own tab without touching other sessions and closing a file ends its session only. Numbers unchanged |
| 2026-10-12 | minor | `.fig` export checked by opening written files in other software (gap 1): layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft, after fixes to what the export writes (visible document and pages, the format's schema numbering, outlines, text settings, font style names, line height units); effects such as glass and noise, images and prototype interactions still unchecked there. Numbers unchanged |
| 2026-10-12 | minor | Public `figcraft-server` hardened against resource exhaustion after a security review: messages capped by what a connection is for (a file's size when open), byte-bounded send queues and a bound on bytes waiting for the hub, files checked as they grow (refused batches roll back with the reason; per-address and total caps count growth), the core's recent-batch memory bounded in bytes, no confirmation for changes that couldn't be written, team libraries only in key-published files on public servers with unpacking bounded by the file cap, usage reports kept in memory, layered IPv6 limits (/64, /56, /48), a global publish rate, connections reserved for sessions, per-address download shares, library connections cut on revoke and removal, a private data directory and key, and safer install.sh, systemd and backups. Percentages and hours unchanged |
+59 -13
View File
@@ -1,5 +1,7 @@
//! Join codes on the server: sharing online from one app and joining by code from another (real
//! links over loopback), codes kept only hashed, revoked with their link and gone with their file,
//! links over loopback; stopping revokes the others' links and codes and disconnects them while
//! the owner stays in the file, and sharing again makes new ones for the same file), codes kept
//! only hashed, revoked with their link and gone with their file,
//! the limits on wrong codes, junk requests, and clients from before join codes.
use std::net::IpAddr;
@@ -78,6 +80,15 @@ fn a_file_shared_online_is_joined_by_its_codes() {
assert_eq!(shared["code"], edit.as_str(), "the code shown lets people edit unless asked otherwise");
let p = a.live().published.clone().unwrap();
assert!(p.owner.is_some(), "the owner token is kept as before");
// Others get links of their own (the owner keeps the file's first ones): four in all.
let edit_link = shared["share"]["editLink"].as_str().unwrap().to_string();
assert!(edit_link != p.edit && shared["share"]["viewLink"] != p.view.as_str() && shared["share"]["shared"] == true);
let ids = |dir: &std::path::Path| -> Vec<String> {
let links: Value = serde_json::from_str(&std::fs::read_to_string(dir.join("files").join(&p.file).join("links.json")).unwrap()).unwrap();
links.as_array().unwrap().iter().map(|l| l["id"].as_str().unwrap().to_string()).collect()
};
let before_stop = ids(dir.path());
assert_eq!(before_stop.len(), 4);
// On disk: hashes only, of the codes as of the links' tokens.
let links = std::fs::read_to_string(dir.path().join("files").join(&p.file).join("links.json")).unwrap();
for secret in [edit.as_str(), view.as_str(), &edit.replace('-', ""), &view.replace('-', "")] {
@@ -90,7 +101,7 @@ fn a_file_shared_online_is_joined_by_its_codes() {
assert_eq!(ex(&mut b, "collab.join", json!({"code": edit.to_lowercase().replace('-', " ")}))["status"], "connecting");
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]) && s[0].live().peers.len() == 1);
assert_eq!(b.live_summary()["access"], "edit");
assert_eq!(b.live_summary()["link"], p.edit.as_str(), "joined as with the link: reconnects need no code");
assert_eq!(b.live_summary()["link"], edit_link.as_str(), "joined as with the shared link: reconnects need no code");
let before = layers(&a);
ex(&mut a, "shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 30}));
pump(&mut [&mut a, &mut b], "Ada's edit", |s| layers(s[1]) == before + 1);
@@ -108,21 +119,49 @@ fn a_file_shared_online_is_joined_by_its_codes() {
ex(&mut d, "collab.join", json!({"code": "000-000-000"}));
pump(&mut [&mut d], "the refusal", |s| failed(s[0]).is_some());
assert_eq!(failed(&d).unwrap(), NO_CODE);
// Stop sharing: the file leaves the server, everyone is told, and the code no longer joins.
assert_eq!(ex(&mut a, "collab.stop", json!({}))["ended"], true);
let folder = dir.path().join("files").join(&p.file);
pump(&mut [&mut a, &mut b, &mut c], "the file gone", |s| !folder.exists() && failed(s[1]).is_some() && failed(s[2]).is_some());
assert!(failed(&b).unwrap().contains("removed"), "{:?}", failed(&b));
// Stop sharing: the others are out at once and told why; the file and its owner stay.
assert_eq!(ex(&mut a, "collab.stop", json!({})), json!({"ended": false, "shared": false}));
pump(&mut [&mut a, &mut b, &mut c], "the others out", |s| {
!s[1].live().status.active() && !s[2].live().status.active() && s[0].live().peers.is_empty()
});
for guest in [&b, &c] {
assert!(guest.message.as_deref().unwrap_or_default().contains("Sharing was stopped"), "{:?}", guest.message);
assert_eq!(guest.live().status, Status::Off, "over, not failed: nothing retries");
}
assert_eq!(layers(&b), before + 2, "Bo keeps the copy");
let folder = dir.path().join("files").join(&p.file);
assert!(folder.exists() && live(&a), "the file stays on the server and Ada stays in it");
let stopped = a.live_summary();
assert_eq!((&stopped["share"]["shared"], &stopped["code"], &stopped["published"]["file"]), (&json!(false), &Value::Null, &json!(p.file)));
assert_eq!(ids(dir.path()), before_stop[..2], "the owner's links are left, the shared ones are gone");
// Neither the codes nor the links get anyone in any more.
let mut e = app("Ed", &url);
for old in [&edit, &view] {
ex(&mut e, "collab.join", json!({"code": old}));
pump(&mut [&mut e], "the stopped code", |s| failed(s[0]).is_some());
assert_eq!(failed(&e).unwrap(), CODE_GONE);
}
ex(&mut e, "collab.join", json!({"link": edit_link}));
pump(&mut [&mut e], "the stopped link", |s| failed(s[0]).is_some_and(|why| why.contains("isn't valid")));
// The owner still edits the server's file.
ex(&mut a, "shape.rectangle", json!({"x": 200, "y": 10, "width": 40, "height": 30}));
pump(&mut [&mut a], "the owner's edit confirmed", |s| layers(s[0]) == before + 3 && s[0].live().client().is_some_and(|c| c.pending() == 0));
// Sharing again: new codes and links for the same file (nothing is published twice).
let again = ex(&mut a, "collab.share", json!({}));
let (edit2, link2) = (again["code"].as_str().unwrap().to_string(), again["share"]["editLink"].as_str().unwrap().to_string());
assert!(is_code(&edit2) && edit2 != edit && link2 != edit_link && again["published"]["file"] == p.file.as_str(), "{again}");
assert_eq!(dir.path().join("files").read_dir().unwrap().count(), 1, "still one file on the server");
assert_eq!(ids(dir.path()).len(), 4);
ex(&mut b, "collab.join", json!({"code": edit2}));
pump(&mut [&mut a, &mut b], "Bo back in", |s| live(s[1]) && layers(s[1]) == before + 3 && s[0].live().peers.len() == 1);
// The old code is still gone.
ex(&mut e, "collab.join", json!({"code": edit}));
pump(&mut [&mut e], "the stopped code", |s| failed(s[0]).is_some());
assert_eq!(failed(&e).unwrap(), CODE_GONE);
pump(&mut [&mut e], "the old code", |s| failed(s[0]).is_some_and(|why| why == CODE_GONE));
server.stop();
}
#[test]
fn a_keyed_share_gets_codes_too_and_leaving_keeps_the_file() {
fn a_keyed_share_gets_codes_and_stops_with_the_key() {
let dir = TempDir::new("codes-keyed");
let mut server = crate::start(&config(dir.path())).unwrap();
let url = format!("ws://{}/", server.addr());
@@ -132,11 +171,18 @@ fn a_keyed_share_gets_codes_too_and_leaving_keeps_the_file() {
let shown = a.live_summary()["code"].as_str().unwrap().to_string();
assert!(is_code(&shown));
assert!(!a.live_summary().to_string().contains("key="), "the key is nowhere in the summary");
// No owner token: stopping leaves the session, the operator's file stays, and so does its code.
ex(&mut a, "collab.stop", json!({}));
let mut b = app("Bo", &url);
ex(&mut b, "collab.join", json!({"code": shown}));
pump(&mut [&mut b], "Bo in", |s| live(s[0]));
pump(&mut [&mut a, &mut b], "Bo in", |s| live(s[1]));
// No owner token: the key the address carried stops the sharing (the file is the operator's).
assert_eq!(ex(&mut a, "collab.stop", json!({}))["shared"], false);
pump(&mut [&mut a, &mut b], "Bo out", |s| !s[1].live().status.active());
assert!(live(&a) && !a.live_summary().to_string().contains("key="));
ex(&mut b, "collab.join", json!({"code": shown}));
pump(&mut [&mut b], "the stopped code", |s| failed(s[0]).is_some_and(|why| why == CODE_GONE));
// Leaving keeps the file there.
ex(&mut a, "collab.leave", json!({}));
assert_eq!(dir.path().join("files").read_dir().unwrap().count(), 1);
server.stop();
}
+3 -3
View File
@@ -160,9 +160,9 @@ fn revoked_links_stop_working_at_once() {
assert_eq!((files.len(), files[0].people), (1, 3));
// Revoked: Cy is out at once, the others stay.
assert_eq!(ask(Admin::Revoke { file: file.clone(), link: review.link.id.clone() }), AdminReply::Done);
pump(&mut [&mut a, &mut b, &mut c], "Cy out", |s| {
matches!(&s[2].live().status, Status::Failed { reason } if reason.contains("revoked")) && s[0].live().peers.len() == 1
});
pump(&mut [&mut a, &mut b, &mut c], "Cy out", |s| s[2].live().status == Status::Off && s[0].live().peers.len() == 1);
// Told as when a host stops: the session is over for Cy, whose copy stays open (no retries).
assert!(c.message.as_deref().unwrap_or_default().contains("Sharing was stopped"), "{:?}", c.message);
assert!(live(&b));
// The link no longer joins.
c.execute("collab.join", &json!({"link": link})).unwrap();
+43 -6
View File
@@ -139,7 +139,7 @@ fn old_server() -> (Endpoint, std::thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let at = Endpoint::plain(listener.local_addr().unwrap().to_string());
let t = std::thread::spawn(move || {
for stream in listener.incoming().take(3).flatten() {
for stream in listener.incoming().take(2).flatten() {
let Ok(mut ws) = tungstenite::accept(stream) else { continue };
let _ = ws.read();
let refused = ServerMsg::Refused { reason: "that message couldn't be read".into() };
@@ -151,6 +151,40 @@ fn old_server() -> (Endpoint, std::thread::JoinHandle<()>) {
(at, t)
}
/// A server from before join codes that manages a file's links as it always did: two connections,
/// the first makes links, the second (asked for codes) can't read the request.
fn old_server_with_links() -> (Endpoint, std::thread::JoinHandle<()>) {
use figcraft_collab::message::{Admin, AdminReply, LinkInfo, NewLink};
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let at = Endpoint::plain(listener.local_addr().unwrap().to_string());
let t = std::thread::spawn(move || {
let mut made = 0;
for stream in listener.incoming().take(2).flatten() {
let Ok(mut ws) = tungstenite::accept(stream) else { continue };
let say = |ws: &mut tungstenite::WebSocket<std::net::TcpStream>, m: ServerMsg| {
let _ = ws.send(Message::Binary(Bytes::from(message::encode(&m).unwrap())));
};
while let Ok(Message::Binary(b)) = ws.read() {
match message::decode::<ClientMsg>(&b, usize::MAX) {
Ok(ClientMsg::OpenHosting(_)) => say(&mut ws, ServerMsg::Hosting(Box::default())),
Ok(ClientMsg::Admin(Admin::CreateLink { file, access, label })) => {
made += 1;
let link = LinkInfo { id: format!("link{made}"), file, access, label, created: 0 };
say(&mut ws, ServerMsg::Admin(Box::new(AdminReply::Link(NewLink { link, token: format!("TokenTokenTokenToken0{made}") }))));
}
_ => {
say(&mut ws, ServerMsg::Refused { reason: "that message couldn't be read".into() });
break;
}
}
}
let _ = ws.close(None);
while ws.read().is_ok() {}
}
});
(at, t)
}
#[test]
fn a_server_older_than_join_codes_says_so_and_the_links_still_work() {
let (server, thread) = old_server();
@@ -164,11 +198,14 @@ fn a_server_older_than_join_codes_says_so_and_the_links_still_work() {
pump(&mut [&mut guest], "the answer", |s| matches!(s[0].live().status, Status::Failed { .. }));
let why = guest.live().status.label();
assert!(why.contains("doesn't know join codes yet") && why.contains("invite link"), "{why}");
// Asking it for codes: none, without an error (the app shows the links).
assert_eq!(
manage::codes(&server, None, "0123456789abcdef", "AbCdEfGhIjKlMnOpQrStUv", &["AbCdEfGhIjKlMnOpQrStUv"], &limits, Duration::from_secs(5)),
None
);
thread.join().unwrap();
// Sharing a file there: it makes the links (it always could) but no codes, without an error:
// the app shows the links.
let (server, thread) = old_server_with_links();
let shared =
manage::share(&server, None, "0123456789abcdef", "AbCdEfGhIjKlMnOpQrStUv", &[], true, &limits, Duration::from_secs(5)).unwrap().unwrap();
assert!(shared.edit.code.is_none() && shared.view.as_ref().is_some_and(|v| v.code.is_none()));
assert!(shared.edit.link.contains("&file=0123456789abcdef&token=TokenTokenTokenToken01") && shared.edit.id == "link1");
thread.join().unwrap();
}
+12 -17
View File
@@ -39,8 +39,8 @@ pub struct Publish {
/// The file's name on the server.
pub name: String,
pub doc: Arc<Document>,
/// Ask the server for join codes for the file's links ([`Event::Codes`]).
pub codes: bool,
/// Share the file once it is there: links and join codes for others ([`Event::Shared`]).
pub share: bool,
}
/// A joined (or joining) session. Dropping it leaves.
@@ -98,15 +98,20 @@ impl Guest {
Ok(done) => done,
Err(e) => return inbox.push(Event::Status(Status::Failed { reason: format!("Couldn't publish to the server: {e}") })),
};
// Before anyone hears the file is there, so the codes show with it (a server from
// before join codes has none: the links are shown).
let codes = if job.codes { join_codes(&job, &cfg, &published) } else { None };
// Before anyone hears the file is there, so what others join with shows with it. The
// file's first links stay its owner's (this app joins with one): others get their own,
// which stopping takes away again.
let shared = job.share.then(|| {
let owner = published.owner.as_deref().unwrap_or_default();
let made = manage::share(&job.server, job.key.as_deref(), &published.file, owner, &[], true, &cfg.limits, cfg.connect_timeout);
made.and_then(|s| s.ok_or_else(|| "the server didn't make the links".to_string()))
});
if let Some(h) = hosting {
inbox.push(Event::Hosting(h));
}
inbox.push(Event::Published(published));
if let Some((edit, view)) = codes {
inbox.push(Event::Codes { edit, view });
if let Some(shared) = shared {
inbox.push(Event::Shared(shared));
}
// Left while uploading: the file stays on the server, nobody joins it. Anything else
// that came meanwhile (presence) waits for the session.
@@ -206,16 +211,6 @@ fn upload(job: &Publish, mut cfg: GuestConfig) -> Result<(GuestConfig, Published
Ok((cfg, published, hosting))
}
/// The join codes of the file just published: its edit link's and its view link's.
fn join_codes(job: &Publish, cfg: &GuestConfig, published: &Published) -> Option<(String, String)> {
let view = Invite::parse(&published.view).ok()?;
let tokens = [cfg.invite.token.as_str(), view.token.as_str()];
let owner = published.owner.as_deref().unwrap_or_default();
let codes = manage::codes(&job.server, job.key.as_deref(), &published.file, owner, &tokens, &cfg.limits, cfg.connect_timeout)?;
let mut codes = codes.into_iter();
Some((codes.next()?, codes.next()?))
}
fn connect(cfg: &GuestConfig) -> Result<Conn, String> {
let (rd, wr, sock) = open(&cfg.invite.server, cfg.connect_timeout, &cfg.limits)?;
let (reader, _) = tungstenite::client::client_with_config(cfg.invite.endpoint(), rd, Some(config(cfg.limits.max_server_message)))
+22 -9
View File
@@ -1104,6 +1104,10 @@ fn unsupported(protocol: u32) -> String {
format!("protocol {protocol} isn't supported (this server speaks {PROTOCOL})")
}
/// What someone in a file is told when the link they joined with is revoked (its owner stopped
/// sharing): the session's end for them.
pub const STOPPED: &str = "Sharing was stopped: the link you joined with no longer works. Your copy stays open";
/// What everyone in a file is told when its changes couldn't be saved (they reconnect).
const UNSAVED: &str = "The server couldn't save the latest changes; reconnecting";
@@ -1447,15 +1451,24 @@ impl<B: Backend> Hub<B> {
fn handled(&mut self, conn: u64, handled: Handled) {
self.answer(conn, &ServerMsg::Admin(Box::new(handled.reply)));
if let Some((file, link)) = handled.revoked {
let users: Vec<u64> = self
.conns
.iter()
.filter(|(_, c)| {
matches!(&c.kind, Kind::Joined { file: f, link: Some(l), .. } | Kind::Library { file: f, link: Some(l), .. } if *f == file && *l == link)
})
.map(|(k, _)| *k)
.collect();
self.kick(&users, "the link you joined with was revoked");
let with = |joined: bool| -> Vec<u64> {
let uses = |c: &Conn| match &c.kind {
Kind::Joined { file: f, link: Some(l), .. } => joined && *f == file && *l == link,
Kind::Library { file: f, link: Some(l), .. } => !joined && *f == file && *l == link,
_ => false,
};
self.conns.iter().filter(|(_, c)| uses(c)).map(|(k, _)| *k).collect()
};
let (people, libraries) = (with(true), with(false));
// People in the file are told the session is over for them, as when a host stops (they
// keep their copy; nothing retries); a library request is refused.
for conn in &people {
if let Some(c) = self.conns.get(conn) {
let _ = c.out.tx.try_send(Out::Close(CloseCode::Away, STOPPED.into()));
}
self.gone(*conn);
}
self.kick(&libraries, "the link you joined with was revoked");
}
if let Some(file) = handled.removed {
let users: Vec<u64> = self
+15 -2
View File
@@ -9,7 +9,7 @@ use figcraft_collab::code;
use figcraft_collab::message::{Access, Admin, AdminReply, ClientMsg, LibraryReply, LibraryReq, PluginReply, PluginReq};
use figcraft_engine::live::{
Bind, Event, HostInfo, HostOptions, LiveLink, NO_SESSION, NetReply, NetRequest, PageInfo, Presence, PublishOptions, Published, Role, SessionId,
SessionInfo,
SessionInfo, Shared,
};
use crate::guest::{Guest, GuestConfig, Publish, upload_file};
@@ -176,7 +176,7 @@ impl LiveLink for NetLink {
// Until the upload is done the invite names only the server.
let cfg = self.guest_config(Invite { server: server.clone(), file: String::new(), token: String::new() }, None, &o.me);
let inbox = self.sessions.inbox();
let job = Publish { server, key, name: o.name, doc: o.doc, codes: o.codes };
let job = Publish { server, key, name: o.name, doc: o.doc, share: o.share };
let guest = Guest::publish(job, cfg, inbox.clone()).map_err(|e| format!("can't start the connection: {e}"))?;
self.started(session, Active::Guest(guest), inbox);
Ok(())
@@ -198,6 +198,10 @@ impl LiveLink for NetLink {
hosting(server, file, owner, req, &self.limits)
}
fn share(&mut self, server: &str, file: &str, owner: &str, revoke: &[String], create: bool) -> Result<Option<Shared>, String> {
share(server, file, owner, revoke, create, &self.limits)
}
fn background(&mut self, job: u64, req: NetRequest) -> bool {
let (inbox, limits) = (self.libraries.clone(), self.limits);
let (failed, failure) = (inbox.clone(), req.failure());
@@ -207,6 +211,7 @@ impl LiveLink for NetLink {
NetRequest::Library { link, req } => NetReply::Library(manage::library(&link, req, &limits, CONNECT)),
NetRequest::Upload(o) => NetReply::Upload(upload(o, &limits)),
NetRequest::Hosting { server, file, owner, req } => NetReply::Hosting(hosting(&server, &file, &owner, req, &limits)),
NetRequest::Share { server, file, owner, revoke, create } => NetReply::Share(share(&server, &file, &owner, &revoke, create, &limits)),
};
inbox.push(Event::Net { job, reply: Box::new(reply) });
});
@@ -286,6 +291,14 @@ fn hosting(server: &str, file: &str, owner: &str, req: Option<Admin>, limits: &N
Ok((info, reply))
}
/// See [`LiveLink::share`]: as the server's operator when the address carries its key, else as
/// the file's owner.
fn share(server: &str, file: &str, owner: &str, revoke: &[String], create: bool, limits: &NetLimits) -> Result<Option<Shared>, String> {
let (endpoint, query) = Endpoint::parse(server)?;
let key = query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v).filter(|k| !k.trim().is_empty());
manage::share(&endpoint, key.as_deref(), file, owner, revoke, create, limits, CONNECT)
}
/// One request to the plugin directory at `server` (its key in the address's query, if any).
fn plugins(server: &str, req: PluginReq, limits: &NetLimits) -> Result<PluginReply, String> {
let (server, query) = Endpoint::parse(server)?;
+42 -18
View File
@@ -7,9 +7,10 @@
use std::time::Duration;
use figcraft_collab::message::{
self, Admin, AdminReply, ClientMsg, HostInfo, LibraryReply, LibraryReq, Manage, OpenHosting, OpenLibrary, OpenPlugins, PROTOCOL, PluginReply,
PluginReq, Resolve, ServerMsg,
self, Access, Admin, AdminReply, ClientMsg, HostInfo, LibraryReply, LibraryReq, Manage, NewLink, OpenHosting, OpenLibrary, OpenPlugins, PROTOCOL,
PluginReply, PluginReq, Resolve, ServerMsg,
};
use figcraft_engine::live::{Shared, SharedLink};
use tungstenite::{Bytes, Message, WebSocket};
use crate::invite::{Endpoint, Invite};
@@ -96,33 +97,56 @@ pub fn resolve(server: &Endpoint, code: &str, limits: &NetLimits, timeout: Durat
Ok(answer)
}
/// Join codes for the links of `file` with these `tokens`, in order, asked as whoever manages the
/// file: with the server's `key`, else with the file's `owner` token. `None` when the server
/// doesn't hand codes out (it is older than them) or can't be asked: the links still work.
pub fn codes(
/// Change who else gets into `file`, as whoever manages it (with the server's `key`, else with the
/// file's `owner` token): revoke the links with the ids in `revoke` (whoever joined with them is
/// disconnected, and their join codes stop working; one already gone is fine), then, to `create`,
/// make a new edit link and a new view link and ask for their join codes: what others join with.
/// A server older than join codes makes the links without codes.
pub fn share(
server: &Endpoint,
key: Option<&str>,
file: &str,
owner: &str,
tokens: &[&str],
revoke: &[String],
create: bool,
limits: &NetLimits,
timeout: Duration,
) -> Option<Vec<String>> {
let requests = tokens.iter().map(|t| Admin::Code { file: file.to_string(), token: (*t).to_string() }).collect();
let replies = match key {
) -> Result<Option<Shared>, String> {
let ask = |requests: Vec<Admin>| match key {
Some(key) => request(server, key, requests, limits, timeout),
None => hosting(server, file, owner, requests, limits, timeout).map(|(_, replies)| replies),
};
let codes: Option<Vec<String>> = replies
let link = |access: Access| Admin::CreateLink { file: file.to_string(), access, label: format!("Shared ({})", access.id()) };
let mut requests: Vec<Admin> = revoke.iter().map(|id| Admin::Revoke { file: file.to_string(), link: id.clone() }).collect();
requests.extend(create.then(|| [link(Access::Edit), link(Access::View)]).into_iter().flatten());
let mut made = Vec::new();
for reply in ask(requests)? {
match reply {
AdminReply::Link(l) => made.push(l),
// A link that is gone already is as revoked as it gets.
AdminReply::Failed { reason } if made.is_empty() && reason.contains("no such link") => {}
AdminReply::Failed { reason } => return Err(reason),
_ => {}
}
}
let (Some(view), Some(edit)) = (made.pop(), made.pop()) else {
return if create { Err("the server didn't make the links".into()) } else { Ok(None) };
};
// Their codes, on a connection of its own: a server from before join codes can't read the
// request and closes it (the links are shared without codes then).
let code = |token: &str| Admin::Code { file: file.to_string(), token: token.to_string() };
let mut codes = ask(vec![code(&edit.token), code(&view.token)])
.map_err(|e| log::info!("live session: no join codes from {}: {e}", server.display()))
.ok()?
.unwrap_or_default()
.into_iter()
.map(|r| match r {
AdminReply::Code { code } => Some(code),
_ => None,
})
.collect();
codes.filter(|c| c.len() == tokens.len())
.map(|r| if let AdminReply::Code { code } = r { Some(code) } else { None });
let mut shared = |l: NewLink| SharedLink {
link: Invite { server: server.clone(), file: file.to_string(), token: l.token }.link(),
id: l.link.id,
code: codes.next().flatten(),
};
let edit = shared(edit);
Ok(Some(Shared { edit, view: Some(shared(view)) }))
}
/// Send one library request about the file behind share link `link`; the reply. Fails when the
+1
View File
@@ -112,6 +112,7 @@ fn answer(s: &mut Session, wire: &Arc<Mutex<Wire>>) -> usize {
NetRequest::Library { req, .. } => NetReply::Library(Ok(library(&mut w, req))),
NetRequest::Upload(_) => NetReply::Upload(Err("no".into())),
NetRequest::Hosting { .. } => NetReply::Hosting(Err("no".into())),
NetRequest::Share { .. } => NetReply::Share(Err("no".into())),
};
w.answers.push(Event::Net { job: *job, reply: Box::new(reply) });
}
+1 -1
View File
@@ -360,7 +360,7 @@ fn publish_to_server(s: &mut Session, p: &Value) -> Result<Value> {
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(), codes: false };
let opts = PublishOptions { server, key, name: doc.name.clone(), me: String::new(), doc: doc.clone(), share: false };
let file = match s.net(NetRequest::Upload(opts))?.0 {
NetReply::Upload(r) => r.map_err(|e| bad(cmd, e))?,
_ => return Err(EngineError::Other("the server answered something else".into())),
+143 -62
View File
@@ -195,26 +195,55 @@ pub enum Via {
Local,
}
/// A file shared with a join code (`collab.share`).
/// One way into a shared file: an invite link, and the join code that stands for it.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SharedLink {
/// The link's id on its server (what revokes it; empty for a session this app hosts).
pub id: String,
/// The invite link (it carries its secret).
pub link: String,
/// Its join code's symbols. `None`: there is none (a server from before join codes, a host
/// without an IPv4 address): the link is shared instead.
pub code: Option<String>,
}
/// What others join a shared file with: to edit it and, on a server, to only view it.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Shared {
pub edit: SharedLink,
pub view: Option<SharedLink>,
}
/// A file shared with a join code (`collab.share`). Online, the file is its owner's on the
/// server: sharing gives others links (and codes) of their own, and stopping takes those away
/// while the owner stays in the file.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Share {
pub via: Via,
/// Online: the server's address (without a key).
/// Online: the server's address as it was given (it may carry the server's key: never shown).
pub server: String,
/// What the code shown lets people do.
pub access: Access,
/// The join codes (their symbols) that let people edit and only view. `None`: not there yet,
/// or there is none (a server from before join codes, a host without an IPv4 address; on the
/// local network everyone edits): the invite links are shared instead.
pub edit: Option<String>,
pub view: Option<String>,
/// What others join with now. `None`: on its way, or (online) not shared at the moment: the
/// file is on the server, open only to its owner.
pub links: Option<Shared>,
}
impl Share {
/// The code to show: the one for [`access`](Self::access), grouped (`K7M-2QX-9TD`).
/// The way in to show: the one for [`access`](Self::access).
fn shown(&self) -> Option<&SharedLink> {
let links = self.links.as_ref()?;
if self.access == Access::View { links.view.as_ref() } else { Some(&links.edit) }
}
/// The code to show, grouped (`K7M-2QX-9TD`).
pub fn code(&self) -> Option<String> {
let code = if self.access == Access::View { &self.view } else { &self.edit };
code.as_deref().map(code::grouped)
self.shown()?.code.as_deref().map(code::grouped)
}
/// The invite link to give with (or in place of) the code.
pub fn link(&self) -> Option<&str> {
self.shown().map(|l| l.link.as_str())
}
}
@@ -263,12 +292,9 @@ pub enum Event {
},
/// The document is on the server (`collab.publish`); joining it follows.
Published(Published),
/// The server's join codes for the published file's edit link and view link (`collab.share`;
/// a server from before join codes sends none).
Codes {
edit: String,
view: String,
},
/// The published file is shared (`collab.share`): the links and codes others join with, or
/// why the server made none.
Shared(std::result::Result<Shared, String>),
/// What the server says about its hosting (operator, terms, this file's expiry).
Hosting(HostInfo),
/// The server's answer to `collab.report`: kept, or why not.
@@ -297,6 +323,10 @@ pub enum NetRequest {
Library { link: String, req: LibraryReq },
/// Keep a file on a server without joining it ([`LiveLink::upload`]).
Upload(PublishOptions),
/// Change who else gets into `file` on the server at `server` (its address, with `?key=…`, or
/// the file's `owner` token): revoke the links with these ids, then, to `create`, make a new
/// edit link and view link with their join codes ([`LiveLink::share`]).
Share { server: String, file: String, owner: String, revoke: Vec<String>, create: bool },
/// Ask the server at `server` about its hosting and, with a file's owner token, manage that
/// file ([`LiveLink::hosting`]).
Hosting { server: String, file: String, owner: String, req: Option<Admin> },
@@ -308,7 +338,7 @@ impl NetRequest {
match self {
NetRequest::Plugins { .. } => crate::plugin_directory::NO_DIRECTORY,
NetRequest::Library { .. } | NetRequest::Upload(_) => NO_TEAM_LIBRARIES,
NetRequest::Hosting { .. } => NO_HOSTING,
NetRequest::Hosting { .. } | NetRequest::Share { .. } => NO_HOSTING,
}
}
@@ -319,6 +349,7 @@ impl NetRequest {
NetRequest::Library { .. } => |why| NetReply::Library(Err(why)),
NetRequest::Upload(_) => |why| NetReply::Upload(Err(why)),
NetRequest::Hosting { .. } => |why| NetReply::Hosting(Err(why)),
NetRequest::Share { .. } => |why| NetReply::Share(Err(why)),
}
}
@@ -333,6 +364,9 @@ impl NetRequest {
(NetRequest::Hosting { server: a, file: f, req: x, .. }, NetRequest::Hosting { server: b, file: g, req: y, .. }) => {
a == b && f == g && x.as_ref().map(discriminant) == y.as_ref().map(discriminant)
}
(NetRequest::Share { server: a, file: f, create: x, .. }, NetRequest::Share { server: b, file: g, create: y, .. }) => {
a == b && f == g && x == y
}
_ => false,
}
}
@@ -343,6 +377,8 @@ impl NetRequest {
pub enum NetReply {
Plugins(std::result::Result<PluginReply, String>),
Library(std::result::Result<LibraryReply, String>),
/// The new links, when some were asked for.
Share(std::result::Result<Option<Shared>, String>),
Upload(std::result::Result<Published, String>),
/// What the server says about its hosting, and its answer to the request, if one was sent.
Hosting(std::result::Result<(HostInfo, Option<AdminReply>), String>),
@@ -390,8 +426,9 @@ pub struct PublishOptions {
/// Your name in the session (empty: the server picks one).
pub me: String,
pub doc: Arc<Document>,
/// Ask the server for join codes for the file's links ([`Event::Codes`]; `collab.share`).
pub codes: bool,
/// Share the file once it is there: links and join codes for others ([`Event::Shared`];
/// `collab.share`).
pub share: bool,
}
/// The networking behind live sessions, installed by the app ([`Lives::install`]). It carries
@@ -446,9 +483,14 @@ pub trait LiveLink: Send {
) -> std::result::Result<(HostInfo, Option<AdminReply>), String> {
Err(NO_HOSTING.into())
}
/// See [`NetRequest::Share`]; waits for the server. Errors never echo the key or the token.
fn share(&mut self, _server: &str, _file: &str, _owner: &str, _revoke: &[String], _create: bool) -> std::result::Result<Option<Shared>, String> {
Err(NO_HOSTING.into())
}
/// Send `req` and wait for the answer (through the methods above).
fn request(&mut self, req: NetRequest) -> NetReply {
match req {
NetRequest::Share { server, file, owner, revoke, create } => NetReply::Share(self.share(&server, &file, &owner, &revoke, create)),
NetRequest::Plugins { server, req } => NetReply::Plugins(self.plugins(&server, req)),
NetRequest::Library { link, req } => NetReply::Library(self.library(&link, req)),
NetRequest::Upload(o) => NetReply::Upload(self.upload(o)),
@@ -934,13 +976,12 @@ impl Session {
}
live.published = Some(p);
}
Event::Codes { edit, view } => {
// Only what a server made: nine symbols each, or nothing.
let online = |c: String| code::normalize(&c).ok().filter(|c| c.len() == code::ONLINE_LEN);
Event::Shared(Ok(links)) => {
if let Some(share) = live.share.as_mut() {
(share.edit, share.view) = (online(edit), online(view));
share.links = Some(clean_shared(links));
}
}
Event::Shared(Err(why)) => self.message = Some(format!("The file is on the server, but couldn't be shared: {why}")),
Event::Hosting(info) => live.hosting = Some(clean_hosting(info)),
Event::Reported(error) => {
self.message =
@@ -1426,12 +1467,19 @@ impl Session {
"readOnly": l.doc_uid.is_some_and(|u| self.live_read_only(u).is_some()),
"published": l.published,
"code": l.share.as_ref().and_then(Share::code),
"share": l.share.as_ref().map(|sh| json!({
"via": sh.via,
"access": sh.access.id(),
"editCode": sh.edit.as_deref().map(code::grouped),
"viewCode": sh.view.as_deref().map(code::grouped),
})),
"share": l.share.as_ref().map(|sh| {
let (edit, view) = (sh.links.as_ref().map(|s| &s.edit), sh.links.as_ref().and_then(|s| s.view.as_ref()));
let code = |l: Option<&SharedLink>| l.and_then(|l| l.code.as_deref()).map(code::grouped);
json!({
"via": sh.via,
"access": sh.access.id(),
"shared": sh.links.is_some(),
"editCode": code(edit),
"viewCode": code(view),
"editLink": edit.map(|l| l.link.as_str()),
"viewLink": view.map(|l| l.link.as_str()),
})
}),
"hosting": l.hosting.as_ref().map(hosting_json),
"follow": l.follow,
"signals": l.signals,
@@ -1511,14 +1559,14 @@ fn publish(s: &mut Session, p: &Value) -> Result<Value> {
Ok(s.live_summary())
}
/// Start publishing the open file to `server` for command `cmd` (`codes`: asking for join codes
/// too): the new session.
fn start_publish(s: &mut Session, cmd: &str, server: &str, p: &Value, codes: bool) -> Result<SessionId> {
/// Start publishing the open file to `server` for command `cmd` (`share`: with links and join
/// codes for others too): the new session.
fn start_publish(s: &mut Session, cmd: &str, server: &str, p: &Value, share: bool) -> Result<SessionId> {
let key = string(p, "key").map(|k| k.trim().to_string()).filter(|k| !k.is_empty());
let (doc, uid) = s.ds().map(|ds| (ds.doc.clone(), ds.uid))?;
let name = string(p, "name").map(|n| n.trim().chars().filter(|c| !c.is_control()).take(256).collect::<String>()).filter(|n| !n.is_empty());
let me = display_name(&s.prefs);
let opts = PublishOptions { server: server.trim().to_string(), key, name: name.unwrap_or_else(|| doc.name.clone()), me, doc, codes };
let opts = PublishOptions { server: server.trim().to_string(), key, name: name.unwrap_or_else(|| doc.name.clone()), me, doc, share };
let (id, ()) = s.live.start(cmd, |l, id| l.publish(id, opts).map_err(|e| bad(cmd, e)))?;
// This tab becomes the server file's synced document once it is there.
s.live.open(Live { id, status: Status::Connecting, doc_uid: Some(uid), tab: Some(uid), ..Live::default() });
@@ -1539,14 +1587,25 @@ fn share(s: &mut Session, p: &Value) -> Result<Value> {
None => None,
Some(a) => Some(Access::parse(&a).ok_or_else(|| bad(CMD, "`access` must be \"edit\" or \"view\""))?),
};
// Already shared: only which code is shown changes.
// A share of this file is there already: only which code is shown changes, or (the file is
// on the server, not shared at the moment) it gets new links and codes. Nothing is published
// twice.
if let Some(shared) = s.live().share.as_ref().filter(|_| s.live().status.active()).map(|sh| sh.via) {
if get(p, "via").is_some() && shared != via {
return Err(bad(CMD, "this file is shared the other way already: stop sharing it first (collab.stop)"));
return Err(bad(
CMD,
"this file is shared the other way already: stop sharing it first (collab.stop), or leave its session (collab.leave)",
));
}
if access == Some(Access::View) && shared == Via::Local {
return Err(bad(CMD, LOCAL_EDITS));
}
if let Some(req) = share_request(s.live(), Vec::new(), true).filter(|_| s.live().share.as_ref().is_some_and(|sh| sh.links.is_none())) {
let links = share_answer(s, req)?.ok_or_else(|| EngineError::Other("the server made no links".into()))?;
if let Some(sh) = s.live_mut().and_then(|l| l.share.as_mut()) {
sh.links = Some(clean_shared(links));
}
}
if let (Some(a), Some(sh)) = (access, s.live_mut().and_then(|l| l.share.as_mut())) {
sh.access = a;
}
@@ -1566,15 +1625,45 @@ fn share(s: &mut Session, p: &Value) -> Result<Value> {
}
};
if let Some(live) = s.live.sessions.get_mut(&id) {
// On the local network the code says where the session is and its secret, as its link does.
let edit = live.info.as_ref().and_then(|i| code::Local::from_link(&i.link)).map(|l| l.code());
// Kept without a key the address may carry: stopping asks the server as the file's owner.
let server = server.split('?').next().unwrap_or_default().to_string();
live.share = Some(Share { via, server, access, edit, view: None });
// On the local network the session's own link is what others join with, and the code says
// what the link says: where the session is and its secret. (Online they arrive with the file.)
let links = live.info.as_ref().filter(|_| via == Via::Local).map(|i| {
let code = code::Local::from_link(&i.link).map(|l| l.code());
Shared { edit: SharedLink { id: String::new(), link: i.link.clone(), code }, view: None }
});
live.share = Some(Share { via, server, access, links });
}
Ok(s.live_summary())
}
/// Links and codes as a server gave them, with only codes that are codes (nine symbols).
fn clean_shared(mut links: Shared) -> Shared {
let clean = |l: &mut SharedLink| l.code = l.code.take().and_then(|c| code::normalize(&c).ok()).filter(|c| c.len() == code::ONLINE_LEN);
clean(&mut links.edit);
links.view.as_mut().map(clean);
links
}
/// The request that changes who else gets into the server file `live` is its owner's session of
/// (`None`: it isn't one, or the file isn't there yet).
fn share_request(live: &Live, revoke: Vec<String>, create: bool) -> Option<NetRequest> {
let (share, published) = (live.share.as_ref().filter(|s| s.via == Via::Online)?, live.published.as_ref()?);
if live.status != Status::Live {
return None;
}
let owner = published.owner.clone().unwrap_or_default();
Some(NetRequest::Share { server: share.server.clone(), file: published.file.clone(), owner, revoke, create })
}
/// Send `req` ([`NetRequest::Share`]): the new links, if it made any.
fn share_answer(s: &mut Session, req: NetRequest) -> Result<Option<Shared>> {
match s.net(req)?.0 {
NetReply::Share(Ok(links)) => Ok(links),
NetReply::Share(Err(why)) => Err(EngineError::Other(why)),
_ => Err(EngineError::Other("the server answered something else".into())),
}
}
/// What the server at `server` says about its hosting, and its answer to `req` about `file` with
/// its owner token (`collab.serverInfo`, `collab.manageFile`).
fn hosting_request(s: &mut Session, cmd: &str, server: &str, file: &str, owner: &str, req: Option<Admin>) -> Result<(HostInfo, Option<AdminReply>)> {
@@ -1669,26 +1758,18 @@ fn leave(s: &mut Session, _: &Value) -> Result<Value> {
}
fn stop(s: &mut Session, p: &Value) -> Result<Value> {
// A file shared online was put on the server for this: it goes with the sharing (its codes
// and links stop working; everyone keeps the copy they have).
let live = s.live();
let remove = match (&live.share, &live.published) {
(Some(Share { via: Via::Online, server, .. }), Some(Published { file, owner: Some(owner), .. })) => Some(NetRequest::Hosting {
server: server.clone(),
file: file.clone(),
owner: owner.clone(),
req: Some(Admin::Remove { file: file.clone() }),
}),
_ => None,
};
let out = leave(s, p)?;
// Nobody waits for the answer (job 0 is no command's): a file that stays expires on its own.
if let (Some(req), Some(link)) = (remove, s.live.link())
&& !link.background(0, req.clone())
{
let _ = link.request(req);
// A file shared online stays its owner's on the server: stopping takes the others' links (and
// their codes) away, which disconnects whoever joined with them, and the owner stays in the
// file. Anything else (a session this app hosts, one it joined, a file not shared now) ends.
let shared = s.live().share.as_ref().and_then(|sh| sh.links.as_ref());
let ids: Vec<String> = shared.into_iter().flat_map(|l| [Some(&l.edit), l.view.as_ref()]).flatten().map(|l| l.id.clone()).collect();
let Some(req) = share_request(s.live(), ids, false).filter(|_| shared.is_some()) else { return leave(s, p) };
share_answer(s, req).map_err(|e| EngineError::Other(format!("couldn't stop sharing (the others can still get in): {e}")))?;
if let Some(sh) = s.live_mut().and_then(|l| l.share.as_mut()) {
sh.links = None;
}
Ok(out)
s.message = Some("Sharing stopped: the file stays on the server, open only to you".into());
Ok(json!({"ended": false, "shared": false}))
}
fn follow(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1751,14 +1832,14 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"collab.share",
"Share with a join code",
"{via?: \"online\" (default: the open file is kept on the server in Preferences, `pref.publishServer`, and the server hands out the codes) | \"local\" (this app hosts it for people on the same network; no server), access?: \"edit\" (default) | \"view\" (online only: which code `code` is), server? (online: another server's address, with ?key=… when it needs its key), name? (online: the file's name there), port?, bind?: \"lan\" (default) | \"local\" (this machine only) | an IP address (local)} → the open file's session as `collab.peers` gives it: `code` is what others type to join (`collab.join {code}`, the Join field), `link` an invite link. Local: at once, the code carries the host's IPv4 address, port and secret (K7M2Q-…, 20 symbols). Online: status \"connecting\" first; once live `code` is the server's (K7M-2QX-9TD) and `share` has both (`editCode`, `viewCode`); `code` stays null when the server is older than join codes (share `published.edit` / `published.view` instead). Called again while shared: only switches which code `code` is (`access`). `collab.stop` ends it",
"{via?: \"online\" (default: the open file is kept on the server in Preferences, `pref.publishServer`, and the server hands out the codes) | \"local\" (this app hosts it for people on the same network; no server), access?: \"edit\" (default) | \"view\" (online only: which code `code` is), server? (online: another server's address, with ?key=… when it needs its key), name? (online: the file's name there), port?, bind?: \"lan\" (default) | \"local\" (this machine only) | an IP address (local)} → the open file's session as `collab.peers` gives it: `code` is what others type to join (`collab.join {code}`, the Join field), `link` an invite link. Local: at once, the code carries the host's IPv4 address, port and secret (K7M2Q-…, 20 symbols). Online: status \"connecting\" first; once live `code` is the server's (K7M-2QX-9TD) and `share` is {via, access, shared, editCode, viewCode, editLink, viewLink}: links made for others (the file's own links in `published` stay yours); `code` stays null when the server is older than join codes (give `share.editLink` / `share.viewLink` instead). Called again while shared: only switches which code `code` is (`access`). Called after `collab.stop` on a file that is still on the server: new links and codes for that same file (nothing is published twice). `collab.stop` stops sharing",
false,
share
),
spec!(
"collab.stop",
"Stop live session",
"{} (the open file's session; also leaves a joined session; a file shared online with `collab.share` is removed from the server, so its codes and links stop working: `collab.leave` keeps it there) → {ended}",
"{} → {ended}: the open file's session ends (a session this app hosts stops for everyone; a joined one is left). A file shared online with `collab.share` is different: its sharing stops → {ended: false, shared: false}: the links and codes others got are revoked on the server, whoever joined with them is disconnected at once (they keep their copy), and you stay in the file, which stays on the server, open only to you (`collab.share` shares it again with new codes; `collab.leave` leaves it there; `collab.manageFile {action: \"remove\"}` with its owner token takes it off the server)",
false,
stop
),
@@ -1807,7 +1888,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"collab.peers",
"People in this file",
"{} → the open file's session (each open file has its own or none): {available, canHost (false in the browser: it can only join), session (its id; null: this file isn't in a session), status: off|connecting|live|reconnecting|failed, reason?, role, me, name, color, fileName, pages, link, url, address, network, access: edit|view (view: a server's view link; edits are refused), readOnly (the open file can't be edited now: view only, or being published), published (a file you published: {server, file, name, edit, view}), code (the join code to give others, when the file was shared with `collab.share`; null otherwise or while it is on its way), share ({via: online|local, access, editCode, viewCode} or null), follow, syncing, document (index of the synced open document), editing (the text layer you type in: {node, layer, caret, selection}), peers: [{id, name, color, editing (theirs, same shape, or null), presence: {cursor: [page, x, y], selection, viewport: [page, x, y, w, h], editing_text}}], sessions: [{session, document (index of its open document; null for a join on its way), status, reason, role, fileName, link, peers (how many others)}] (every session of the app)}",
"{} → the open file's session (each open file has its own or none): {available, canHost (false in the browser: it can only join), session (its id; null: this file isn't in a session), status: off|connecting|live|reconnecting|failed, reason?, role, me, name, color, fileName, pages, link, url, address, network, access: edit|view (view: a server's view link; edits are refused), readOnly (the open file can't be edited now: view only, or being published), published (a file you published: {server, file, name, edit, view}), code (the join code to give others, when the file was shared with `collab.share`; null otherwise or while it is on its way), share ({via: online|local, access, shared (false: a file of yours on a server that nobody else gets into now), editCode, viewCode, editLink, viewLink (what others join with; null when not shared)} or null), follow, syncing, document (index of the synced open document), editing (the text layer you type in: {node, layer, caret, selection}), peers: [{id, name, color, editing (theirs, same shape, or null), presence: {cursor: [page, x, y], selection, viewport: [page, x, y, w, h], editing_text}}], sessions: [{session, document (index of its open document; null for a join on its way), status, reason, role, fileName, link, peers (how many others)}] (every session of the app)}",
false,
|s, _| Ok(s.live_summary())
),
+112 -25
View File
@@ -1,5 +1,6 @@
//! Sharing with a join code against a scripted link: `collab.share` online and on the local
//! network, the codes in `collab.peers`, switching what the code lets people do, stopping, and
//! network, the codes in `collab.peers`, switching what the code lets people do, stopping (online:
//! the others' links go, the file and its owner stay; sharing again makes new ones), and
//! `collab.join` with a code of either kind or a link (what typing tolerates, and what it says
//! when it can't be a code).
@@ -7,11 +8,11 @@ use std::net::Ipv4Addr;
use std::sync::{Arc, Mutex};
use figcraft_collab::code::{self, Local};
use figcraft_collab::message::{Admin, ClientMsg};
use figcraft_collab::message::ClientMsg;
use serde_json::{Value, json};
use crate::live::{
Access, Bind, DEFAULT_SERVER, Event, HostOptions, LiveLink, NetRequest, Presence, PublishOptions, Published, Role, SessionInfo, Status,
Access, Bind, DEFAULT_SERVER, Event, HostOptions, LiveLink, Presence, PublishOptions, Published, Role, SessionInfo, Shared, SharedLink, Status,
join_target,
};
use crate::{EngineError, Session};
@@ -22,7 +23,10 @@ struct Script {
hosted: Vec<HostOptions>,
published: Vec<PublishOptions>,
joined: Vec<String>,
asked: Vec<(u64, NetRequest)>,
/// What the server was asked about a file's sharing: (server, file, owner, revoke, create).
asked: Vec<(String, String, String, Vec<String>, bool)>,
/// The server can't be reached.
offline: bool,
left: Vec<u64>,
events: Vec<Event>,
session: u64,
@@ -69,9 +73,15 @@ impl LiveLink for Link {
s.published.push(opts);
Ok(())
}
fn background(&mut self, job: u64, req: NetRequest) -> bool {
self.0.lock().unwrap().asked.push((job, req));
true
fn share(&mut self, server: &str, file: &str, owner: &str, revoke: &[String], create: bool) -> Result<Option<Shared>, String> {
let mut s = self.0.lock().unwrap();
if s.offline {
return Err("no answer from the server".into());
}
s.asked.push((server.into(), file.into(), owner.into(), revoke.to_vec(), create));
// Each time the file is shared the server makes new links, with new codes.
let n = s.asked.iter().filter(|a| a.4).count();
Ok(create.then(|| links(n, Some((&format!("NEWED1T0{n}"), &format!("NEWV1EW0{n}"))))))
}
fn leave(&mut self, session: u64) {
self.0.lock().unwrap().left.push(session);
@@ -100,7 +110,19 @@ fn err(s: &mut Session, cmd: &str, p: Value) -> String {
s.execute(cmd, &p).unwrap_err().to_string()
}
/// The file is on the server and joined: its links, then (when given) its codes.
/// The links a server makes for others the `n`th time a file is shared, with their codes (a
/// server from before join codes has none).
fn links(n: usize, codes: Option<(&str, &str)>) -> Shared {
let link = |what: &str, code: Option<&str>| SharedLink {
id: format!("{what}{n}"),
link: format!("figcraft://join?server=wss://share.example:443/&file=f1&token=Share{what}Share{what}0{n}"),
code: code.map(str::to_string),
};
Shared { edit: link("edit", codes.map(|c| c.0)), view: Some(link("view", codes.map(|c| c.1))) }
}
/// The file is on the server and joined: its owner's links, and what others join with (codes
/// too, when given).
fn on_the_server(s: &mut Session, script: &Arc<Mutex<Script>>, codes: Option<(&str, &str)>) {
let link = |token: &str| format!("figcraft://join?server=wss://share.example:443/&file=f1&token={token}");
let published = Published {
@@ -125,13 +147,19 @@ fn on_the_server(s: &mut Session, script: &Arc<Mutex<Script>>, codes: Option<(&s
network: true,
access: Access::Edit,
};
let mut events = vec![Event::Published(published)];
events.extend(codes.map(|(edit, view)| Event::Codes { edit: edit.into(), view: view.into() }));
events.push(Event::Welcome { info, peers: vec![] });
let events = vec![Event::Published(published), Event::Shared(Ok(links(0, codes))), Event::Welcome { info, peers: vec![] }];
script.lock().unwrap().events.extend(events);
s.live_poll();
}
/// The file reached the server and its owner is in it, without links for others.
fn on_the_server_unshared(s: &mut Session, script: &Arc<Mutex<Script>>) {
on_the_server(s, script, None);
if let Some(sh) = s.live_mut().and_then(|l| l.share.as_mut()) {
sh.links = None;
}
}
#[test]
fn the_built_in_server_is_the_default_and_can_be_changed_or_turned_off() {
let (mut s, _) = session();
@@ -166,13 +194,19 @@ fn sharing_online_publishes_to_the_server_in_preferences_and_shows_its_code() {
{
let sc = script.lock().unwrap();
let [p] = sc.published.as_slice() else { panic!("published {}", sc.published.len()) };
assert_eq!((p.server.as_str(), p.key.as_deref(), p.name.as_str(), p.codes), ("wss://share.example/", None, "Untitled", true));
assert_eq!((p.server.as_str(), p.key.as_deref(), p.name.as_str(), p.share), ("wss://share.example/", None, "Untitled", true));
assert!(sc.hosted.is_empty());
}
on_the_server(&mut s, &script, Some(("K7M2QX9TD", "4FHJ8R2WN")));
let r = ex(&mut s, "collab.peers", json!({}));
assert_eq!((&r["status"], &r["code"]), (&json!("live"), &json!("K7M-2QX-9TD")));
assert_eq!(r["share"], json!({"via": "online", "access": "edit", "editCode": "K7M-2QX-9TD", "viewCode": "4FH-J8R-2WN"}));
assert_eq!(
(&r["share"]["shared"], &r["share"]["editCode"], &r["share"]["viewCode"]),
(&json!(true), &json!("K7M-2QX-9TD"), &json!("4FH-J8R-2WN"))
);
// What others get are links of their own, not the owner's.
let (edit_link, own) = (r["share"]["editLink"].as_str().unwrap(), r["published"]["edit"].as_str().unwrap());
assert!(edit_link.contains("ShareeditShareedit00") && edit_link != own && r["link"] == own, "{edit_link}");
assert!(s.message.is_none(), "the popover shows the code: no toast about publishing");
// Asking again only switches which code is the one to give out.
let r = ex(&mut s, "collab.share", json!({"access": "view"}));
@@ -182,15 +216,54 @@ fn sharing_online_publishes_to_the_server_in_preferences_and_shows_its_code() {
assert_eq!(script.lock().unwrap().published.len(), 1);
assert!(err(&mut s, "collab.share", json!({"via": "local"})).contains("stop sharing it first"));
assert!(err(&mut s, "collab.share", json!({"access": "admin"})).contains("`access` must be"));
// Stopping: the session ends and the server is asked, as the file's owner, to remove it.
// Stopping while the server can't be reached stops nothing, and says so.
script.lock().unwrap().offline = true;
assert!(err(&mut s, "collab.stop", json!({})).contains("couldn't stop sharing"));
assert_eq!(ex(&mut s, "collab.peers", json!({}))["code"], "K7M-2QX-9TD");
script.lock().unwrap().offline = false;
// Stopping: the server is asked, as the file's owner, to revoke the others' links. The file
// stays there and its owner stays in it: nothing is left, nothing is removed.
assert_eq!(ex(&mut s, "collab.stop", json!({})), json!({"ended": false, "shared": false}));
{
let sc = script.lock().unwrap();
assert!(sc.left.is_empty(), "the owner stays in the file");
let want = (
"wss://share.example/".to_string(),
"f1".to_string(),
"OwnerOwnerOwnerOwner00".to_string(),
vec!["edit0".to_string(), "view0".to_string()],
false,
);
assert_eq!(sc.asked, [want]);
}
let r = ex(&mut s, "collab.peers", json!({}));
assert_eq!((&r["status"], &r["syncing"].is_boolean(), &r["code"]), (&json!("live"), &true, &Value::Null));
assert_eq!(
r["share"],
json!({"via": "online", "access": "edit", "shared": false, "editCode": null, "viewCode": null, "editLink": null, "viewLink": null})
);
assert!(r["published"]["owner"].is_string(), "still the owner's file on the server");
assert!(s.message.as_deref().unwrap_or_default().contains("stays on the server"));
// Sharing again: new links and codes for the same file, nothing published twice.
let r = ex(&mut s, "collab.share", json!({}));
assert_eq!((&r["code"], &r["share"]["shared"], &r["share"]["viewCode"]), (&json!("NEW-ED1-T01"), &json!(true), &json!("NEW-V1E-W01")));
assert!(r["share"]["editLink"].as_str().unwrap().contains("ShareeditShareedit01"));
{
let sc = script.lock().unwrap();
assert_eq!(sc.published.len(), 1);
assert_eq!(sc.asked[1], ("wss://share.example/".to_string(), "f1".to_string(), "OwnerOwnerOwnerOwner00".to_string(), vec![], true));
}
assert_eq!(ex(&mut s, "collab.share", json!({}))["code"], "NEW-ED1-T01", "shared already: nothing more is asked");
assert_eq!(script.lock().unwrap().asked.len(), 2);
// Stopping again revokes the new ones; then `collab.stop` on a file that isn't shared leaves
// its session like any other (the file stays on the server).
ex(&mut s, "collab.stop", json!({}));
assert_eq!(script.lock().unwrap().asked[2].3, ["edit1", "view1"]);
assert_eq!(ex(&mut s, "collab.stop", json!({}))["ended"], true);
let sc = script.lock().unwrap();
assert_eq!(sc.left, vec![1]);
let [(0, NetRequest::Hosting { server, file, owner, req: Some(Admin::Remove { file: removed }) })] = sc.asked.as_slice() else {
panic!("asked {:?}", sc.asked)
};
assert_eq!((server.as_str(), file.as_str(), owner.as_str(), removed.as_str()), ("wss://share.example/", "f1", "OwnerOwnerOwnerOwner00", "f1"));
drop(sc);
{
let sc = script.lock().unwrap();
assert_eq!((sc.left.clone(), sc.asked.len()), (vec![1], 3));
}
let r = ex(&mut s, "collab.peers", json!({}));
assert_eq!((&r["status"], &r["code"], &r["share"]), (&json!("off"), &Value::Null, &Value::Null));
}
@@ -206,15 +279,29 @@ fn a_server_without_join_codes_leaves_the_links() {
on_the_server(&mut s, &script, None);
let r = ex(&mut s, "collab.peers", json!({}));
assert_eq!((&r["status"], &r["code"], &r["share"]["editCode"]), (&json!("live"), &Value::Null, &Value::Null));
assert!(r["published"]["edit"].as_str().unwrap().contains("token="), "the links are what there is to share");
assert!(r["share"]["editLink"].as_str().unwrap().contains("token="), "the links are what there is to share");
assert!(!r.to_string().contains("KeyKey"), "the server's key is nowhere in the summary");
// Codes that aren't codes are not shown either.
script.lock().unwrap().events.push(Event::Codes { edit: "<script>".into(), view: "K7M2QX9TDX".into() });
script.lock().unwrap().events.push(Event::Shared(Ok(links(0, Some(("<script>", "K7M2QX9TDX"))))));
s.live_poll();
assert_eq!(ex(&mut s, "collab.peers", json!({}))["share"]["viewCode"], Value::Null);
let r = ex(&mut s, "collab.peers", json!({}));
assert_eq!((&r["share"]["editCode"], &r["share"]["viewCode"], &r["share"]["shared"]), (&Value::Null, &Value::Null, &json!(true)));
// Stopping works there too (links are older than codes); the operator's key goes with the
// address, since there is no owner token.
assert_eq!(ex(&mut s, "collab.stop", json!({}))["shared"], false);
{
let sc = script.lock().unwrap();
assert_eq!(sc.asked[0].0, "wss://old.example/?key=KeyKeyKeyKeyKeyKeyKey0");
}
// `collab.leave` keeps the file on the server: nothing is asked of it.
ex(&mut s, "collab.leave", json!({}));
assert!(script.lock().unwrap().asked.is_empty());
assert_eq!(script.lock().unwrap().asked.len(), 1);
// A server that couldn't make the links: the file is there, not shared, and it says why.
ex(&mut s, "collab.share", json!({"server": "wss://full.example/"}));
script.lock().unwrap().events.extend([Event::Shared(Err("a file has at most 100 links: revoke one first".into()))]);
on_the_server_unshared(&mut s, &script);
assert!(s.message.as_deref().unwrap_or_default().contains("couldn't be shared: a file has at most 100 links"));
assert_eq!(ex(&mut s, "collab.peers", json!({}))["share"]["shared"], false);
}
#[test]
+7 -1
View File
@@ -804,6 +804,7 @@ name = "English"
"Not in this font" = "Not in this font"
"Not properties, kept as written: {names}" = "Not properties, kept as written: {names}"
"Not read yet" = "Not read yet"
"Not shared" = "Not shared"
"Note {n}" = "Note {n}"
"Nothing changed since this was marked ready for dev." = "Nothing changed since this was marked ready for dev."
"Nothing here yet. Set a frame's status to list it." = "Nothing here yet. Set a frame's status to list it."
@@ -849,6 +850,7 @@ name = "English"
"Options" = "Options"
"Orange" = "Orange"
"Other apps may not open every .fig file exported here." = "Other apps may not open every .fig file exported here."
"Others lose access and keep their copy; the file stays on the server for you" = "Others lose access and keep their copy; the file stays on the server for you"
"Others type this under Share ▸ Join with a code" = "Others type this under Share ▸ Join with a code"
"Outline stroke" = "Outline stroke"
"Outline text" = "Outline text"
@@ -973,6 +975,7 @@ name = "English"
"Remove auto layout" = "Remove auto layout"
"Remove dev resource" = "Remove dev resource"
"Remove export setting" = "Remove export setting"
"Remove from server…" = "Remove from server…"
"Remove guide" = "Remove guide"
"Remove interaction" = "Remove interaction"
"Remove library from this file" = "Remove library from this file"
@@ -986,9 +989,10 @@ name = "English"
"Remove text from path" = "Remove text from path"
"Remove the else and its actions" = "Remove the else and its actions"
"Remove this condition and its actions" = "Remove this condition and its actions"
"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here."
"Removed" = "Removed"
"Removed from the server" = "Removed from the server"
"Removed since it was marked" = "Removed since it was marked"
"Removes the file from the server; everyone keeps their copy" = "Removes the file from the server; everyone keeps their copy"
"Rename" = "Rename"
"Rename duplicated layers" = "Rename duplicated layers"
"Rename folder" = "Rename folder"
@@ -1247,6 +1251,7 @@ name = "English"
"This condition is edited as an expression" = "This condition is edited as an expression"
"This file" = "This file"
"This file has unsaved changes" = "This file has unsaved changes"
"This file is on {server}, open only to you. Share it to let others in with a new code." = "This file is on {server}, open only to you. Share it to let others in with a new code."
"This frame is new on the branch." = "This frame is new on the branch."
"This frame isn't on the branch." = "This frame isn't on the branch."
"This server is open to the public: no key needed." = "This server is open to the public: no key needed."
@@ -1370,6 +1375,7 @@ name = "English"
"You'll get the shared file as a new tab and edit it together." = "You'll get the shared file as a new tab and edit it together."
"You're spotlighting: everyone follows your view" = "You're spotlighting: everyone follows your view"
"Your copy of the file stays open" = "Your copy of the file stays open"
"Your copy stays open; the file stays on the server" = "Your copy stays open; the file stays on the server"
"Your name" = "Your name"
"Your name in comments" = "Your name in comments"
"Your system asks for reduced motion: animations stay off." = "Your system asks for reduced motion: animations stay off."
+7 -1
View File
@@ -805,6 +805,7 @@ status = "draft — needs native review"
"Not in this font" = "No está en esta fuente"
"Not properties, kept as written: {names}" = "No son propiedades, se quedan tal cual: {names}"
"Not read yet" = "Aún no leída"
"Not shared" = "Sin compartir"
"Note {n}" = "Nota {n}"
"Nothing changed since this was marked ready for dev." = "Nada cambió desde que se marcó como listo para desarrollo."
"Nothing here yet. Set a frame's status to list it." = "Aún no hay nada. Asigna un estado a un marco para verlo aquí."
@@ -850,6 +851,7 @@ status = "draft — needs native review"
"Options" = "Opciones"
"Orange" = "Naranja"
"Other apps may not open every .fig file exported here." = "Puede que otras aplicaciones no abran todos los archivos .fig exportados aquí."
"Others lose access and keep their copy; the file stays on the server for you" = "Los demás pierden el acceso y conservan su copia; el archivo se queda en el servidor para ti"
"Others type this under Share ▸ Join with a code" = "Los demás lo escriben en Compartir ▸ Unirse con un código"
"Outline stroke" = "Contornear trazo"
"Outline text" = "Texto como contornos"
@@ -974,6 +976,7 @@ status = "draft — needs native review"
"Remove auto layout" = "Quitar diseño automático"
"Remove dev resource" = "Quitar recurso de desarrollo"
"Remove export setting" = "Quitar ajuste de exportación"
"Remove from server…" = "Quitar del servidor…"
"Remove guide" = "Quitar guía"
"Remove interaction" = "Quitar interacción"
"Remove library from this file" = "Quitar biblioteca de este archivo"
@@ -987,9 +990,10 @@ status = "draft — needs native review"
"Remove text from path" = "Quitar texto del trazado"
"Remove the else and its actions" = "Quitar el «si no» y sus acciones"
"Remove this condition and its actions" = "Quitar esta condición y sus acciones"
"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "¿Quitar este archivo del servidor? No se podrá volver a compartir ni abrir desde allí. Tu copia sigue abierta aquí."
"Removed" = "Eliminado"
"Removed from the server" = "Quitado del servidor"
"Removed since it was marked" = "Eliminada desde que se marcó"
"Removes the file from the server; everyone keeps their copy" = "Quita el archivo del servidor; cada persona conserva su copia"
"Rename" = "Cambiar nombre"
"Rename duplicated layers" = "Renombrar capas duplicadas"
"Rename folder" = "Cambiar nombre de la carpeta"
@@ -1248,6 +1252,7 @@ status = "draft — needs native review"
"This condition is edited as an expression" = "Esta condición se edita como expresión"
"This file" = "Este archivo"
"This file has unsaved changes" = "Este archivo tiene cambios sin guardar"
"This file is on {server}, open only to you. Share it to let others in with a new code." = "Este archivo está en {server}, abierto solo para ti. Compártelo para dejar entrar a otros con un código nuevo."
"This frame is new on the branch." = "Este marco es nuevo en la rama."
"This frame isn't on the branch." = "Este marco no está en la rama."
"This server is open to the public: no key needed." = "Este servidor está abierto al público: no hace falta clave."
@@ -1371,6 +1376,7 @@ status = "draft — needs native review"
"You'll get the shared file as a new tab and edit it together." = "Recibirás el archivo compartido en una pestaña nueva y lo editaréis en conjunto."
"You're spotlighting: everyone follows your view" = "Estás en el foco: todos siguen tu vista"
"Your copy of the file stays open" = "Tu copia del archivo sigue abierta"
"Your copy stays open; the file stays on the server" = "Tu copia sigue abierta; el archivo se queda en el servidor"
"Your name" = "Tu nombre"
"Your name in comments" = "Tu nombre en los comentarios"
"Your system asks for reduced motion: animations stay off." = "Tu sistema pide reducir el movimiento: las animaciones siguen desactivadas."
+94 -31
View File
@@ -102,6 +102,8 @@ pub struct LiveUi {
/// The join started from the home screen: what it is doing shows under the field, and its
/// file's tab shows when it opens.
pub home_join: Option<SessionId>,
/// Remove from server… was clicked: its confirmation shows.
pub confirm_remove: bool,
/// What the server for online sharing said about its hosting (`collab.serverInfo`), and the
/// address asked.
pub server_info: Option<(String, Value)>,
@@ -561,6 +563,8 @@ pub fn share_popover(app: &mut FigApp, anchor: &egui::Response) {
let publishing = status == Status::Connecting && role.is_none() && app.session.live().doc_uid.is_some();
let (line, col) = match (&status, role) {
(Status::Off, _) => (String::new(), t.text_secondary),
// On the server and not shared at the moment: the file is its owner's alone.
(Status::Live, _) if share.as_ref().is_some_and(|s| s.links.is_none()) => (tr("Not shared").to_string(), t.text_secondary),
(Status::Live, _) if share.as_ref().is_some_and(|s| s.via == Via::Online) => {
(trf("Sharing online · {people}", &[("people", &people_count(others))]), t.success)
}
@@ -584,6 +588,7 @@ pub fn share_popover(app: &mut FigApp, anchor: &egui::Response) {
}
match (&status, role, &share) {
(Status::Off | Status::Failed { .. }, ..) => start_ui(app, ui, &t),
(Status::Live, _, Some(share)) if share.links.is_none() => private_ui(app, ui, &t, share),
(_, _, Some(share)) => share_ui(app, ui, &t, share),
(Status::Live, Some(Role::Host), _) => host_ui(app, ui),
_ => guest_ui(app, ui, &t),
@@ -653,7 +658,7 @@ pub fn join_row(app: &mut FigApp, ui: &mut Ui, width: f32) -> Option<SessionId>
/// dashes and case don't matter in a code) and a Join button; Enter joins too. The session of the
/// join that started (the field is cleared); what can't be joined says why and stays to be fixed.
pub fn join_field(app: &mut FigApp, ui: &mut Ui, typed: &mut String, id: egui::Id, width: f32, focus: bool) -> Option<SessionId> {
let resp = ui.add(egui::TextEdit::singleline(typed).hint_text(tr("Code or invite link")).desired_width(width).id(id));
let resp = widgets::text_input(ui, id, typed, width, tr("Code or invite link"));
// It may hold a link with its secret: it isn't read out.
a11y::text(&resp, tr("Join code or invite link"), "");
if focus {
@@ -720,35 +725,96 @@ fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
}
app.run("collab.share", params);
}
join_and_advanced(app, ui, t, |app, ui, t| {
if local {
if widgets::checkbox(ui, app.ui.live.this_computer, tr("This computer only")) {
app.ui.live.this_computer = !app.ui.live.this_computer;
}
ui.horizontal(|ui| {
widgets::label(ui, tr("Port"), t.text);
if let Some(port) = widgets::text_field_named(ui, egui::Id::new("live_port"), &app.ui.live.port, 70.0, tr("Port")) {
// Digits only; empty: the usual port, or any free one.
app.ui.live.port = port.trim().parse::<u16>().map(|p| p.to_string()).unwrap_or_default();
}
});
} else {
widgets::label(ui, tr("Another server, or one that needs its key. The usual server is set in Preferences."), t.text_secondary);
if widgets::button(ui, tr("Publish to another server…"), false, true).clicked() {
open_publish(app);
ui.close();
}
}
});
}
/// Under what starts a share: the join field, then the Advanced part (`advanced` draws it while
/// it is open).
fn join_and_advanced(app: &mut FigApp, ui: &mut Ui, t: &Tokens, advanced: impl FnOnce(&mut FigApp, &mut Ui, &Tokens)) {
ui.separator();
widgets::label(ui, tr("Join with a code"), t.text);
join_row(app, ui, JOIN_WIDTH);
if widgets::disclosure(ui, tr("Advanced"), app.ui.live.advanced) {
app.ui.live.advanced = !app.ui.live.advanced;
}
if !app.ui.live.advanced {
return;
}
if local {
if widgets::checkbox(ui, app.ui.live.this_computer, tr("This computer only")) {
app.ui.live.this_computer = !app.ui.live.this_computer;
}
ui.horizontal(|ui| {
widgets::label(ui, tr("Port"), t.text);
if let Some(port) = widgets::text_field_named(ui, egui::Id::new("live_port"), &app.ui.live.port, 70.0, tr("Port")) {
// Digits only; empty: the usual port, or any free one.
app.ui.live.port = port.trim().parse::<u16>().map(|p| p.to_string()).unwrap_or_default();
}
});
} else {
widgets::label(ui, tr("Another server, or one that needs its key. The usual server is set in Preferences."), t.text_secondary);
if widgets::button(ui, tr("Publish to another server…"), false, true).clicked() {
open_publish(app);
ui.close();
}
if app.ui.live.advanced {
advanced(app, ui, t);
}
}
/// The file is on a server and not shared now: it is its owner's alone. Start sharing gives
/// others new links and codes for this same file; taking it off the server is a separate,
/// confirmed step under Advanced.
fn private_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens, share: &Share) {
let host = server_host(share.server.split('?').next().unwrap_or_default()).to_string();
widgets::label(
ui,
&trf("This file is on {server}, open only to you. Share it to let others in with a new code.", &[("server", &host)]),
t.text_secondary,
);
if let Some(h) = app.session.live().hosting.clone() {
host_line(ui, t, &h.operator, &h.terms);
expiry_line(ui, t, h.expires);
}
if app.session.waiting_for(SHARE) {
widgets::label(ui, tr("Starting to share…"), t.text_secondary);
} else if widgets::button(ui, tr("Start sharing"), true, true).clicked() {
app.run_background(&ui.ctx().clone(), SHARE, json!({"via": "online"}), Box::new(|_, _, _| {}));
}
// Whoever published it without the server's key holds its owner token: they can remove it.
let published = app.session.live().published.clone();
let removable = published.and_then(|p| Some((share.server.clone(), p.file, p.owner?)));
join_and_advanced(app, ui, t, |app, ui, t| {
if widgets::button(ui, tr("Leave session"), false, true).on_hover_text(tr("Your copy stays open; the file stays on the server")).clicked() {
app.run("collab.leave", json!({}));
}
let Some((server, file, owner)) = removable else { return };
if !std::mem::take(&mut app.ui.live.confirm_remove) {
if widgets::button(ui, tr("Remove from server…"), false, true).clicked() {
app.ui.live.confirm_remove = true;
}
return;
}
widgets::label(
ui,
tr("Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here."),
t.danger,
);
ui.horizontal(|ui| {
if widgets::button(ui, tr("Remove"), true, true).clicked() {
// Out of the file first, so its removal isn't this session's failure.
app.run("collab.leave", json!({}));
let params = json!({"server": server, "file": file, "owner": owner, "action": "remove"});
app.run_background(&ui.ctx().clone(), "collab.manageFile", params, Box::new(|app, _, _| app.toast(tr("Removed from the server"))));
} else if !widgets::button(ui, tr("Cancel"), false, true).clicked() {
app.ui.live.confirm_remove = true;
}
});
});
}
const SHARE: &str = "collab.share";
const STOP: &str = "collab.stop";
/// The join field's width in the Share popover (the Join button fits beside it).
const JOIN_WIDTH: f32 = 196.0;
@@ -763,23 +829,20 @@ fn share_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens, share: &Share) {
let online = share.via == Via::Online;
let stop = |app: &mut FigApp, ui: &mut Ui| {
let hint = if online {
tr("Removes the file from the server; everyone keeps their copy")
tr("Others lose access and keep their copy; the file stays on the server for you")
} else {
tr("Ends the session; everyone keeps their copy")
};
if widgets::button(ui, tr("Stop sharing"), false, true).on_hover_text(hint).clicked() {
app.run("collab.stop", json!({}));
// The server is asked without the window waiting for it.
if widgets::button(ui, tr("Stop sharing"), false, !app.session.waiting_for(STOP)).on_hover_text(hint).clicked() {
app.run_background(&ui.ctx().clone(), STOP, json!({}), Box::new(|_, _, _| {}));
}
};
if live.status != Status::Live {
return stop(app, ui);
}
let view = share.access == Access::View;
let link = match (&live.published, &live.info) {
(Some(p), _) => if view { &p.view } else { &p.edit }.clone(),
(None, Some(i)) => i.link.clone(),
(None, None) => String::new(),
};
let link = share.link().unwrap_or_default().to_string();
let me = live.info.as_ref().map(|i| i.name.clone()).unwrap_or_default();
let names: Vec<String> = std::iter::once(trf("{name} (you)", &[("name", &me)])).chain(live.peers.values().map(|p| p.name.clone())).collect();
let hosting = live.hosting.clone();
@@ -807,7 +870,7 @@ fn share_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens, share: &Share) {
}
}
// A server has a link (and a code) for looking only; a session this app hosts doesn't.
if online
if share.links.as_ref().is_some_and(|l| l.view.is_some())
&& let Some(i) = widgets::segmented_text(ui, &[tr("Can edit"), tr("Can view")], usize::from(view))
&& (i == 1) != view
{
@@ -1191,7 +1254,7 @@ pub fn inspect(app: &FigApp) -> serde_json::Value {
"joinDialog": app.ui.live.join.is_some(),
"joinField": app.ui.live.code,
"code": l.share.as_ref().and_then(Share::code),
"share": l.share.as_ref().map(|s| json!({"via": s.via, "access": s.access.id()})),
"share": l.share.as_ref().map(|s| json!({"via": s.via, "access": s.access.id(), "shared": s.links.is_some()})),
"local": app.ui.live.local,
"advanced": app.ui.live.advanced,
"publishDialog": app.ui.live.publish.is_some(),
+109 -19
View File
@@ -1,6 +1,7 @@
//! Sharing with a join code in the window: the Share popover not sharing (online or the local
//! network, one Start button, the rest under Advanced), sharing online (the code large, copying,
//! what it lets people do, who is here, stop), sharing on the local network, a server without
//! what it lets people do, who is here, stop: the file stays on the server, not shared, and can
//! be shared again or removed), sharing on the local network, a server without
//! codes, joining with the one join field (the popover, the home screen, the Join dialog), its
//! errors, the server preference, and an idle popover asking for no frames.
@@ -11,7 +12,8 @@ use egui::{Event, Key, Shape};
use figcraft_engine::figcraft_collab::code::{self, Local};
use figcraft_engine::figcraft_collab::message::ClientMsg;
use figcraft_engine::live::{
Access, DEFAULT_SERVER, Event as LiveEvent, HostOptions, LiveLink, Peer, Presence, PublishOptions, Published, Role, SessionInfo, Status,
Access, DEFAULT_SERVER, Event as LiveEvent, HostOptions, LiveLink, NetReply, NetRequest, Peer, Presence, PublishOptions, Published, Role,
SessionInfo, Shared as SharedLinks, SharedLink, Status,
};
use serde_json::json;
@@ -27,6 +29,22 @@ struct Script {
events: Vec<LiveEvent>,
session: u64,
copied: Vec<String>,
/// Link ids the server was asked to revoke, how often it was asked for new links, the files
/// it was asked to remove, and its answers on their way.
revoked: Vec<String>,
shares: usize,
removed: Vec<String>,
answers: Vec<LiveEvent>,
}
/// The links a server makes for others the `n`th time a file is shared, with their codes.
fn links(n: usize, codes: Option<(&str, &str)>) -> SharedLinks {
let link = |what: &str, code: Option<&str>| SharedLink {
id: format!("{what}{n}"),
link: format!("figcraft://join?server=wss://share.example:443/&file=f1&token=Share{what}Share{what}0{n}"),
code: code.map(str::to_string),
};
SharedLinks { edit: link("edit", codes.map(|c| c.0)), view: Some(link("view", codes.map(|c| c.1))) }
}
type Shared = Arc<Mutex<Script>>;
@@ -74,6 +92,32 @@ impl LiveLink for Link {
s.published.push(opts);
Ok(())
}
fn share(&mut self, _: &str, _: &str, _: &str, revoke: &[String], create: bool) -> Result<Option<SharedLinks>, String> {
let mut s = self.0.lock().unwrap();
s.revoked.extend(revoke.iter().cloned());
s.shares += usize::from(create);
let n = s.shares;
Ok(create.then(|| links(n, Some((&format!("NEWED1T0{n}"), &format!("NEWV1EW0{n}"))))))
}
fn hosting(
&mut self,
_: &str,
file: &str,
_: &str,
req: Option<figcraft_engine::figcraft_collab::message::Admin>,
) -> Result<(figcraft_engine::live::HostInfo, Option<figcraft_engine::figcraft_collab::message::AdminReply>), String> {
use figcraft_engine::figcraft_collab::message::{Admin, AdminReply};
if matches!(req, Some(Admin::Remove { .. })) {
self.0.lock().unwrap().removed.push(file.to_string());
}
Ok((Default::default(), Some(AdminReply::Done)))
}
fn background(&mut self, job: u64, req: NetRequest) -> bool {
// Answered at the next poll, as the network would.
let reply: NetReply = self.request(req);
self.0.lock().unwrap().answers.push(LiveEvent::Net { job, reply: Box::new(reply) });
true
}
fn leave(&mut self, _: u64) {
self.0.lock().unwrap().left += 1;
}
@@ -82,7 +126,8 @@ impl LiveLink for Link {
fn poll(&mut self) -> Vec<(u64, LiveEvent)> {
let mut s = self.0.lock().unwrap();
let session = s.session;
std::mem::take(&mut s.events).into_iter().map(|e| (session, e)).collect()
let answers: Vec<(u64, LiveEvent)> = std::mem::take(&mut s.answers).into_iter().map(|e| (0, e)).collect();
answers.into_iter().chain(std::mem::take(&mut s.events).into_iter().map(|e| (session, e))).collect()
}
}
@@ -141,10 +186,8 @@ fn on_the_server(h: &mut Harness, script: &Shared, codes: Option<(&str, &str)>)
owner: Some("OwnerOwnerOwnerOwner00".into()),
expires: None,
};
let mut events = vec![LiveEvent::Published(published.clone())];
events.extend(codes.map(|(edit, view)| LiveEvent::Codes { edit: edit.into(), view: view.into() }));
events.push(LiveEvent::Welcome { info: info(Role::Guest, &published.edit), peers: vec![] });
script.lock().unwrap().events.extend(events);
let welcome = LiveEvent::Welcome { info: info(Role::Guest, &published.edit), peers: vec![] };
script.lock().unwrap().events.extend([LiveEvent::Published(published), LiveEvent::Shared(Ok(links(0, codes))), welcome]);
h.frames(3)
}
@@ -197,7 +240,7 @@ fn sharing_online_shows_the_code_copies_it_and_stops() {
{
let s = script.lock().unwrap();
let [p] = s.published.as_slice() else { panic!("published {}", s.published.len()) };
assert!(p.server == "wss://share.example/" && p.key.is_none() && p.codes, "the server in Preferences, asked for codes");
assert!(p.server == "wss://share.example/" && p.key.is_none() && p.share, "the server in Preferences, shared once there");
}
assert!(has_text(&shapes, "Starting to share…") && has_text(&shapes, "Stop sharing"));
assert!(!has_text(&shapes, "Start sharing"));
@@ -218,10 +261,8 @@ fn sharing_online_shows_the_code_copies_it_and_stops() {
// Copying.
let shapes = click(&mut h, &shapes, "Copy code");
let shapes = click(&mut h, &shapes, "Copy link");
assert_eq!(
script.lock().unwrap().copied,
["K7M-2QX-9TD", "figcraft://join?server=wss://share.example:443/&file=f1&token=EditEditEditEditEdit00"]
);
// The link is one made for others, not the owner's own.
assert_eq!(script.lock().unwrap().copied, ["K7M-2QX-9TD", "figcraft://join?server=wss://share.example:443/&file=f1&token=ShareeditShareedit00"]);
// Someone joins: they are listed.
script.lock().unwrap().events.push(LiveEvent::PeerJoined(Peer { client_id: 2, name: "Hana".into(), color: "#9747FF".into() }));
let shapes_now = h.frames(2);
@@ -231,13 +272,57 @@ fn sharing_online_shows_the_code_copies_it_and_stops() {
assert!(has_text(&shapes, "4FH-J8R-2WN") && !has_text(&shapes, "K7M-2QX-9TD"));
assert!(has_text(&shapes, "Anyone with this code can look at the file, not change it."));
let shapes = click(&mut h, &shapes, "Copy link");
assert!(script.lock().unwrap().copied.last().unwrap().ends_with("token=ViewViewViewViewView00"));
assert!(script.lock().unwrap().copied.last().unwrap().ends_with("token=ShareviewShareview00"));
let shapes = click(&mut h, &shapes, "Can edit");
assert!(has_text(&shapes, "K7M-2QX-9TD"));
// Stop: back to the start, the session left.
let shapes = click(&mut h, &shapes, "Stop sharing");
assert!(has_text(&shapes, "Start sharing") && has_text(&shapes, "Share this file") && !has_text(&shapes, "K7M-2QX-9TD"));
assert_eq!((h.app.session.live().status.clone(), script.lock().unwrap().left), (Status::Off, 1));
// Stop: the others' links are revoked; the file stays on the server and you stay in it.
click(&mut h, &shapes, "Stop sharing");
let shapes = h.frames(3);
assert_eq!(script.lock().unwrap().revoked, ["edit0", "view0"]);
assert_eq!((h.app.session.live().status.clone(), script.lock().unwrap().left), (Status::Live, 0));
for t in
["Not shared", "This file is on share.example, open only to you. Share it to let others in with a new code.", "Start sharing", "Advanced"]
{
assert!(has_text(&shapes, t), "not shared shows {t:?}");
}
assert!(
!has_text(&shapes, "K7M-2QX-9TD")
&& !has_text(&shapes, "Stop sharing")
&& !has_text(&shapes, "Edit link")
&& !has_text(&shapes, "Local network")
);
assert_eq!(crate::live::inspect(&h.app)["share"]["shared"], false);
// Share again: a new code for the same file, nothing published twice.
click(&mut h, &shapes, "Start sharing");
let shapes = h.frames(3);
assert!(has_text(&shapes, "NEW-ED1-T01") && has_text(&shapes, "Stop sharing") && !has_text(&shapes, "K7M-2QX-9TD"));
let s = script.lock().unwrap();
assert_eq!((s.published.len(), s.shares), (1, 1));
}
#[test]
fn a_file_that_is_not_shared_can_be_removed_from_the_server_after_asking() {
let (mut h, script) = harness();
let shapes = open(&mut h);
click(&mut h, &shapes, "Start sharing");
let shapes = on_the_server(&mut h, &script, Some(("K7M2QX9TD", "4FHJ8R2WN")));
click(&mut h, &shapes, "Stop sharing");
let shapes = h.frames(3);
// Removing is its own step under Advanced, and asks first.
assert!(!has_text(&shapes, "Remove from server…"));
let shapes = click(&mut h, &shapes, "Advanced");
assert!(has_text(&shapes, "Remove from server…") && has_text(&shapes, "Leave session"));
let shapes = click(&mut h, &shapes, "Remove from server…");
assert!(has_text(&shapes, "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here."));
assert!(script.lock().unwrap().removed.is_empty(), "nothing yet");
let shapes = click(&mut h, &shapes, "Cancel");
assert!(has_text(&shapes, "Remove from server…") && script.lock().unwrap().removed.is_empty());
let shapes = click(&mut h, &shapes, "Remove from server…");
click(&mut h, &shapes, "Remove");
let shapes = h.frames(3);
assert_eq!(script.lock().unwrap().removed, ["f1"]);
assert_eq!(h.app.session.live().status, Status::Off);
assert!(has_text(&shapes, "Share this file") && has_text(&shapes, "Removed from the server"));
}
#[test]
@@ -284,9 +369,14 @@ fn without_a_code_the_link_is_shared_instead() {
));
assert!(!has_text(&shapes, "Copy code"));
let shapes = click(&mut h, &shapes, "Copy");
assert!(script.lock().unwrap().copied[0].ends_with("token=EditEditEditEditEdit00"));
assert!(script.lock().unwrap().copied[0].ends_with("token=ShareeditShareedit00"));
// Stopping works without codes too (links are older than codes).
click(&mut h, &shapes, "Stop sharing");
let shapes = h.frames(3);
assert!(has_text(&shapes, "Not shared") && script.lock().unwrap().revoked == ["edit0", "view0"]);
// A host whose address a code can't carry.
let shapes = click(&mut h, &shapes, "Stop sharing");
h.app.session.execute("collab.leave", &json!({})).unwrap();
let shapes = h.frames(2);
let shapes = click(&mut h, &shapes, "Local network");
h.app.ui.live.port = "6".into();
let shapes = click(&mut h, &shapes, "Start sharing");
+32 -8
View File
@@ -428,14 +428,7 @@ fn text_field_in(ui: &mut Ui, id: Id, value: &str, rect: Rect, placeholder: &str
let mut out = None;
match editing {
Some(mut text) => {
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(8.0, 4.0))));
let r = child.add(
egui::TextEdit::singleline(&mut text)
.id(id.with("te"))
.frame(egui::Frame::NONE)
.font(regular(11.0))
.desired_width(rect.width() - 16.0),
);
let r = field_editor(ui, rect, id.with("te"), &mut text, "");
a11y::own_focus(&r);
keep_focus(&r, started);
let enter = !started && ui.input(|i| i.key_pressed(egui::Key::Enter));
@@ -459,6 +452,37 @@ fn text_field_in(ui: &mut Ui, id: Id, value: &str, rect: Rect, placeholder: &str
out
}
/// The editor inside a field's `rect`: one line of text in the field's font, without a frame of
/// its own (the field draws it), showing `hint` while empty.
fn field_editor(ui: &mut Ui, rect: Rect, id: Id, text: &mut String, hint: &str) -> Response {
let t = Tokens::get(ui.ctx());
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(8.0, 4.0))));
child.add(
egui::TextEdit::singleline(text)
.id(id)
.frame(egui::Frame::NONE)
.font(regular(11.0))
.hint_text(egui::RichText::new(hint).font(regular(11.0)).color(t.text_tertiary))
.desired_width(rect.width() - 16.0),
)
}
/// A text field that is typed into in place: `text` changes as it is typed (the caller decides
/// what Enter does). As tall as a button and drawn like [`text_field`], so the two line up in a
/// row. Returns the editor's response (name it for screen readers with `a11y::text`).
pub fn text_input(ui: &mut Ui, id: Id, text: &mut String, width: f32, hint: &str) -> Response {
let t = Tokens::get(ui.ctx());
let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover());
ui.painter().rect_filled(rect, CornerRadius::same(5), t.field);
let r = field_editor(ui, rect, id, text, hint);
if r.has_focus() {
ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.accent), StrokeKind::Inside);
} else if ui.rect_contains_pointer(rect) {
ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.field_hover_border), StrokeKind::Inside);
}
r
}
/// Focus for a field's editor that appears on the click that starts editing: egui takes focus
/// away from a widget a click lands outside of, and a widget that is new this frame isn't under
/// the pointer yet, so the focus is asked for again once it exists.
+16 -3
View File
@@ -499,9 +499,21 @@ resuming with its offline edits, and leaving the session going back to snapshot
for a code for the edit link and one for the view link (`Admin code`, as the file's owner) and
shows the edit code, `K7M-2QX-9TD`: **nine symbols, 45 random bits**, a different code per link.
A joiner's app sends `Resolve {code}` to its own server in Preferences, gets the file and the
link's token, and joins with them as with the link. **Stop sharing** (`collab.stop`) leaves and
has the server remove the file (`Admin remove` with the owner token): codes and links stop
working and everyone keeps the copy they have. `collab.leave` keeps the file on the server.
link's token, and joins with them as with the link.
- **The file is its owner's.** Publishing gives the file its first edit and view link and its
owner token; those stay with the owner, whose app joins with that edit link. Sharing makes two
more links for others (`Admin createLink`, labelled "Shared (edit)" and "Shared (view)") and
asks for their codes; those are what the popover shows and copies. **Stop sharing**
(`collab.stop`) revokes these two links (`Admin revoke`, as the owner): their codes stop
resolving ("That code has expired or its sharing was stopped"), the links stop joining, and
everyone who joined with them is disconnected at once with close code 1001 and "Sharing was
stopped: the link you joined with no longer works. Your copy stays open", which apps take as
the session's end (no retries; older apps too). The owner stays connected, the file stays on
the server, open only to its owner, and the popover offers Start sharing again: new links and
codes for the same file, nothing published twice. If the server can't be reached, nothing is
stopped and the app says so. Taking the file off the server is a separate, confirmed step
(Share ▸ Advanced ▸ Remove from server…, or `collab.manageFile {action: "remove"}`). Closing
the tab, quitting or `collab.leave` leave the file and its sharing as they are.
- **What the server keeps of a code.** Never the code. It is stretched (SHA-256 of the code under
a fixed label, then 10,000 more rounds); a hash of the result finds the link, and another is the
pad the link's token is wrapped with (XOR), so only the code gives the token back. Lookups
@@ -593,6 +605,7 @@ resuming with its offline edits, and leaving the session going back to snapshot
the file and a link's token; the server joins it with that link's access (`Server::join_as`). A
viewer gets every change and everyone's presence and sends its own presence; each batch it
sends is refused (`Reject`, "you can only view this file") and rolled back. Revoking a link
(people in the file are told as when a host stops: close code 1001 with the reason, final)
(or removing a file) disconnects whoever joined with it, with the reason; the link no longer
joins.
- **Links** name the server's endpoint: `figcraft://join?host=<host:port>&file=…&token=…` for a
+1 -1
View File
@@ -26,7 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Sharing with a join code (M9, from the owner's use: sharing wasn't intuitive): the Share popover is one flow, **Online** (the file is published to the server in Preferences, built in `wss://collab.craftapps.dev/`, and the server names its edit and view links with nine-symbol codes like `K7M-2QX-9TD`) or **Local network** (the app hosts; a 20-symbol code carries its IPv4 address, port and a 44-bit secret, with check bits), one Start sharing button, the code large with Copy code and Copy link, who is here, can edit / can view online, and Stop sharing (online: the file leaves the server); **Join with a code** is one field (popover, home screen, Join dialog) for a code of either kind or a link; addresses, keys and ports moved under Advanced and Preferences ▸ Server for online sharing (reset, or empty to turn online sharing off). Protocol additions within version 2 (`Resolve` / `Resolved`, `Admin code`): the server keeps a stretched hash and the wrapped token, never the code, and limits wrong codes (10 a minute per address, a 15-minute lockout after 10 wrong, 1,000 wrong an hour for everyone); codes go with their link and file. Older servers leave the links in place of a code; older apps join by link. `collab.share`, `collab.join {code}`, `code` and `share` in `collab.peers`; the browser joins by code. Usability and hosting rather than a new capability: percentages and hours unchanged |
| 2026-10-12 | minor | Sharing with a join code (M9, from the owner's use: sharing wasn't intuitive): the Share popover is one flow, **Online** (the file is published to the server in Preferences, built in `wss://collab.craftapps.dev/`, and the server names its edit and view links with nine-symbol codes like `K7M-2QX-9TD`) or **Local network** (the app hosts; a 20-symbol code carries its IPv4 address, port and a 44-bit secret, with check bits), one Start sharing button, the code large with Copy code and Copy link, who is here, can edit / can view online, and Stop sharing (online: the others' links and codes are revoked and they are disconnected, while the file stays on the server with its owner in it and can be shared again with new codes; removing it is a separate, confirmed step); **Join with a code** is one field (popover, home screen, Join dialog) for a code of either kind or a link; addresses, keys and ports moved under Advanced and Preferences ▸ Server for online sharing (reset, or empty to turn online sharing off). Protocol additions within version 2 (`Resolve` / `Resolved`, `Admin code`): the server keeps a stretched hash and the wrapped token, never the code, and limits wrong codes (10 a minute per address, a 15-minute lockout after 10 wrong, 1,000 wrong an hour for everyone); codes go with their link and file. Older servers leave the links in place of a code; older apps join by link. `collab.share`, `collab.join {code}`, `code` and `share` in `collab.peers`; the browser joins by code. Usability and hosting rather than a new capability: percentages and hours unchanged |
| 2026-10-12 | minor | Live sessions are per document (a fix from real use: after hosting or joining in one tab, a new or other file showed that session, and starting one there silently replaced it). Every open file has its own session or none; several run at once and keep syncing behind the tab in front; the Share popover, the people, the cursors, cursor chat, spotlight, follow and the read-only reason are the file in front's (each window its own); the `collab.*` commands act on the active file's session and `collab.peers` lists all of them (`sessions`); joining opens the session's file in its own tab without touching other sessions; closing a file ends its session only. One link carries the sessions, each under its own id (desktop, browser and tests). Percentages and hours unchanged |
| 2026-10-12 | minor | Public `figcraft-server` hardened against resource exhaustion after a security review: messages capped by what a connection is for (a file's size when open), byte-bounded send queues and a bound on bytes waiting for the hub, files checked as they grow (refused batches roll back with the reason; per-address and total caps count growth), the core's recent-batch memory bounded in bytes, no confirmation for changes that couldn't be written, team libraries only in key-published files on public servers with unpacking bounded by the file cap, usage reports kept in memory, layered IPv6 limits (/64, /56, /48), a global publish rate, connections reserved for sessions, per-address download shares, library connections cut on revoke and removal, a private data directory and key, and safer install.sh, systemd and backups. Percentages and hours unchanged |
| 2026-10-12 | minor | A public `figcraft-server` (open hosting): with `open_publish = true` anyone publishes a file without the server key and gets its edit and view links plus an owner token (128 bits, shown once, stored hashed) that lists, makes and revokes its links, renames it or removes it (`collab.manageFile`); limits per address (connections at once and a minute, messages a second, publishes an hour, files and MiB kept; IPv6 by /64; `X-Forwarded-For` believed only from `trusted_proxy`; tables capped at 10,000 addresses) and for everyone (`max_total_mb`), each refusal saying why; files published without the key expire after `idle_days` unopened (the date shown to everyone in the file); moderation (`files list --sort size|age|idle`, `block-ip`, `reports`, a Report button and `collab.report`); the operator's contact and terms shown in the Share dialog; `GET /healthz` on the same port; `packaging/server/install.sh` for a Debian/Ubuntu machine behind Caddy and a nightly backup timer. Protocol additions within version 2, gated like signals (older apps get clear refusals). Hosting infrastructure rather than a Figma feature: percentages and hours unchanged |
+14 -6
View File
@@ -900,14 +900,21 @@ only: a host stops hosting it, a guest leaves it.
FigCraft server unless changed; `{url: ""}` turns online sharing off, `{}` goes back to the
built-in one; `server` names another for this call, with `?key=…` when it needs its key) and
returns `status: "connecting"`; once `collab.peers` says `"live"`, `code` is the code to give
others (`K7M-2QX-9TD`) and `share` is `{via, access, editCode, viewCode}`. `access: "view"` (or
calling again with it while shared) makes `code` the view-only one. A server older than join
codes leaves `code` null: share `published.edit` / `published.view` instead. `via: "local"`
others (`K7M-2QX-9TD`) and `share` is `{via, access, shared, editCode, viewCode, editLink,
viewLink}`: links and codes made for others (`published.edit` / `published.view` and `link` are
your own way in; keep them). `access: "view"` (or calling again with it while shared) makes
`code` the view-only one. A server older than join codes leaves `code` null: give
`share.editLink` / `share.viewLink` instead. `via: "local"`
hosts the document for people on the same network and returns at once with a longer `code`
(20 symbols, carrying this machine's IPv4 address, the port and the session's secret; `bind:
"local"` keeps it to this machine); everyone who joins edits. **Calling it publishes the
document to a server other people run: do it only when asked to share.** `collab.stop {}` ends
it (a file shared online is removed from the server, so its codes and links stop working).
document to a server other people run: do it only when asked to share.** `collab.stop {}` stops
the sharing. Local: the session ends for everyone (`{ended: true}`). Online: `{ended: false,
shared: false}`: the others' links and codes are revoked and they are disconnected at once (with
their copy), while you stay in the file, which stays on the server, open only to you
(`share.shared` is false, `code` null); `collab.share {}` then shares the same file again with
new codes, `collab.leave {}` leaves it there, and `collab.manageFile {action: "remove"}` with
`published.owner` takes it off the server.
- `collab.host {port?, bind?}` starts hosting the active document (a session that document was in
ends; other documents' sessions go on): `bind` is `"local"` (this
machine, the default), `"lan"` (the local network) or an interface address; the port is 47900,
@@ -964,7 +971,8 @@ only: a host stops hosting it, a guest leaves it.
access (edit|view), readOnly (the active document can't be edited now: view only, or being
published), published (a file you published: {server, file, name, edit, view, owner, expires}),
code (the join code to give others when the document was shared with `collab.share`, else
null), share ({via: online|local, access, editCode, viewCode}, or null),
null), share ({via: online|local, access, shared, editCode, viewCode, editLink, viewLink}, or
null; `shared: false`: your file on a server, not shared now),
hosting (what the server of the file you're in says: {open, operator, terms, idleDays, maxFileMb,
expires}, or null),
follow, signals (the host passes cursor chat and spotlights on), chat (your cursor chat),
+11 -4
View File
@@ -77,9 +77,16 @@ Server for online sharing** (the FigCraft server unless you put yours there; Res
turns online sharing off), then shows a **join code** such as `K7M-2QX-9TD` in place of the links.
Someone else types it under **Share ▸ Join with a code** (or on the home screen, or
`collab.join {code}`) with the same server in their Preferences, and is in the file. The popover
switches between the code that lets people edit and the one that only lets them view, and **Stop
sharing** removes the file from the server (with its owner token), so its codes and links stop
working; everyone keeps the copy they have. To use **your** server for this, set its address
switches between the code that lets people edit and the one that only lets them view.
The file is its owner's: the app keeps the file's first links and its owner token for itself and
makes two more links ("Shared (edit)", "Shared (view)" in `figcraft-server links list`) for
everyone else, with their codes. **Stop sharing** revokes those two: their codes and links stop
working at once, whoever joined with them is disconnected ("Sharing was stopped…"; they keep the
copy they have), and the owner stays in the file, which stays on the server, open only to them.
**Start sharing** again gives the same file new links and codes. **Share ▸ Advanced ▸ Remove from
server…** (it asks first) takes the file off the server; closing the tab or quitting changes
nothing on the server. To use **your** server for this, set its address
(`wss://figcraft.example.com/`) in Preferences on every machine that shares or joins; a server
that needs its key is reached under **Advanced ▸ Publish to another server…**, or with
`collab.share {server: "wss://…/?key=…"}`, and gets codes too.
@@ -395,7 +402,7 @@ What someone can do, by what they hold (the server is reached through TLS at the
| Someone with a **join code** | Exactly what its link gives (a view code: the view link's row; an edit code: the edit link's), until the code or its link is gone | Get a code for any other link; learn anything from a wrong code but that it is wrong |
| Someone with a file's **view link** | See that file and everyone's changes and presence in it; read its team library and usage; report it | Change it (the server refuses a viewer's edits); reach any other file; manage links |
| Someone with a file's **edit link** | Change that file, within its size cap; publish its team library (on a public server only for files published with the key); report it | Reach any other file; grow it past `max_file_mb` or its publisher's share; list, make or revoke links; rename or remove it |
| Whoever holds a file's **owner token** (published without the key) | List, make and revoke that file's links (disconnecting whoever joined with a revoked one), ask for join codes for links whose token they hold, rename it on the server, remove it | Touch any other file, even with its id; list files; read reports; block; anything of the plugin directory |
| Whoever holds a file's **owner token** (published without the key) | List, make and revoke that file's links (disconnecting whoever joined with a revoked one, which is how the app stops sharing), ask for join codes for links whose token they hold, rename it on the server, remove it | Touch any other file, even with its id; list files; read reports; block; anything of the plugin directory |
| The **operator** (server key, or the machine) | Everything above for every file, reports, blocks, the plugin directory; read every file on disk | Recover a lost link or owner token (only hashes are kept) |
Limits that hold against everyone: message sizes by what a connection is for, file sizes as they