M9: remove someone from a live session

Right-click a person's avatar ▸ "Remove <name> from session", or
`collab.removePerson {client}`, when you host the session or own the file
on a server. The hub closes their connection as the end of the session for
them (close 1001: they keep their copy and don't reconnect) and bars their
address from that file while it runs, so the code or link they hold no
longer gets them in; everyone else stays connected.

Protocol addition within version 2: `Admin::RemovePeer {file, client}` on
an open hosting connection with the file's owner token (or the server
key). Older servers say they can't.

Tests: loopback (removed, can't rejoin, others stay), engine (host can,
guest can't, bad ids refused), codec round trip; the collab junk sweep
covers the new command.
This commit is contained in:
HaSh3003
2026-10-14 22:32:56 +03:00
parent 833f0d2c27
commit fa0365470e
14 changed files with 335 additions and 7 deletions
+4
View File
@@ -1438,6 +1438,10 @@ impl Backend for Store {
Admin::Blocked => AdminReply::Blocked(self.blocked()).into(),
Admin::Code { file, token } => self.set_code(&file, &token).map_or_else(failed, |code| AdminReply::Code { code }.into()),
Admin::Owned { files } => self.owned(&files).into(),
// start feat: remove-from-session
// The hub removes people itself (it knows who is connected); it never asks the store.
Admin::RemovePeer { .. } => failed("removing someone from a file is done by the server's hub".into()),
// end feat: remove-from-session
Admin::Remove { file } => {
if !self.files.contains_key(&file) {
return failed("this file isn't on the server (it may have been removed)".into());
+11
View File
@@ -14,6 +14,9 @@ use figcraft_engine::live::{Event, PEOPLE};
use crate::hub::{Admission, Backend, Closing, Hosting, Local, clean_name, to_local};
use crate::inbox::Inbox;
pub use crate::join::ENDED;
// start feat: remove-from-session
pub use crate::hub::REMOVED;
// end feat: remove-from-session
use crate::net::NetLimits;
/// What a hosted session needs.
@@ -129,6 +132,14 @@ impl Host {
self.hosting.send(msg);
}
// start feat: remove-from-session
/// Remove client `id` from the session: they are disconnected and can't join again from
/// their address while it runs. The host can't remove themselves.
pub fn remove(&self, id: u32) {
self.hosting.remove(id);
}
// end feat: remove-from-session
/// Cut every connection as a network failure would (no close frame).
#[cfg(test)]
pub(crate) fn cut_connections(&self) {
+99 -2
View File
@@ -21,7 +21,7 @@
//! backend may refuse addresses or limit what they do. A plain `GET /healthz` on the same port is
//! answered `200 ok` for uptime checks.
use std::collections::{BTreeMap, HashMap};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::io::{self, Read, Write};
use std::net::{IpAddr, Ipv6Addr, SocketAddr, TcpListener, TcpStream};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
@@ -413,6 +413,10 @@ enum HubIn {
},
Local(Presence),
LocalMsg(ClientMsg),
// start feat: remove-from-session
/// The person hosting removes client `id` from their file (app sessions).
Remove(u32),
// end feat: remove-from-session
/// Cut every connection without a word (tests: a network failure).
#[cfg(test)]
Cut,
@@ -461,7 +465,15 @@ impl Hosting {
pub(crate) fn start<B: Backend>(listener: TcpListener, backend: B, limits: NetLimits, local: Option<Local>) -> io::Result<Hosting> {
let addr = listener.local_addr()?;
let in_flight = InFlight(Arc::default());
let mut hub = Hub { backend, rooms: HashMap::new(), conns: HashMap::new(), local: None, outbox: Vec::new(), in_flight: in_flight.clone() };
let mut hub = Hub {
backend,
rooms: HashMap::new(),
conns: HashMap::new(),
local: None,
outbox: Vec::new(),
in_flight: in_flight.clone(),
barred: HashMap::new(),
};
if let Some(l) = local {
let mut room = Room::new(l.server);
room.local = Some((l.id, l.inbox));
@@ -551,6 +563,14 @@ impl Hosting {
let _ = self.hub.send(HubIn::LocalMsg(msg));
}
// start feat: remove-from-session
/// The hosting person removes client `id` from their file (app sessions; see
/// [`HubIn::Remove`]).
pub(crate) fn remove(&self, id: u32) {
let _ = self.hub.send(HubIn::Remove(id));
}
// end feat: remove-from-session
/// Cut every connection as a network failure would (no close frame).
#[cfg(test)]
pub(crate) fn cut_connections(&self) {
@@ -1108,6 +1128,18 @@ fn unsupported(protocol: u32) -> String {
/// 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";
// start feat: remove-from-session
/// What someone removed from a file is told: the session's end for them (they keep their copy;
/// nothing retries).
pub const REMOVED: &str = "You were removed from this session. Your copy stays open";
/// Why a removed person's address can't join the file again.
const BARRED: &str = "you were removed from this session";
/// The most addresses one file keeps barred.
const MAX_BARRED: usize = 256;
// end feat: remove-from-session
/// 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";
@@ -1121,6 +1153,10 @@ struct Hub<B> {
local: Option<String>,
/// What the servers said, sent once the backend made what changed durable.
outbox: Vec<(String, Outgoing)>,
// start feat: remove-from-session
/// Addresses ([`ip_key`]) removed from each file: they can't join it again while the hub runs.
barred: HashMap<String, HashSet<IpAddr>>,
// end feat: remove-from-session
}
impl<B: Backend> Hub<B> {
@@ -1208,6 +1244,15 @@ impl<B: Backend> Hub<B> {
self.client_message(&file, id, msg, None);
}
}
// start feat: remove-from-session
HubIn::Remove(id) => {
if let Some(file) = self.local.clone()
&& let Err(why) = self.remove_peer(&file, id)
{
log::info!("live session: couldn't remove client {id}: {why}");
}
}
// end feat: remove-from-session
#[cfg(test)]
HubIn::Cut => {
for c in self.conns.values() {
@@ -1232,6 +1277,16 @@ impl<B: Backend> Hub<B> {
self.client_message(&file, id, msg, Some(bytes));
}
Some(Kind::Hosting { owns }) => match msg {
// start feat: remove-from-session
ClientMsg::Admin(Admin::RemovePeer { file, client }) => {
let reply = if owns.as_deref() == Some(file.as_str()) {
self.removed_reply(&file, client)
} else {
AdminReply::Failed { reason: "an owner token manages only its own file".into() }
};
self.answer(conn, &ServerMsg::Admin(Box::new(reply)));
}
// end feat: remove-from-session
ClientMsg::Admin(req) => {
let owns = owns.clone();
let rooms = &self.rooms;
@@ -1243,6 +1298,12 @@ impl<B: Backend> Hub<B> {
_ => self.kick(&[conn], "only publishing and managing your own files are spoken on this connection"),
},
Some(Kind::Managing) => match msg {
// start feat: remove-from-session
ClientMsg::Admin(Admin::RemovePeer { file, client }) => {
let reply = self.removed_reply(&file, client);
self.answer(conn, &ServerMsg::Admin(Box::new(reply)));
}
// end feat: remove-from-session
ClientMsg::Admin(req) => self.admin(conn, req),
ClientMsg::Ping => self.answer(conn, &ServerMsg::Pong),
_ => self.kick(&[conn], "only requests to manage files are spoken on this connection"),
@@ -1305,6 +1366,12 @@ impl<B: Backend> Hub<B> {
Ok(a) => a,
Err(why) => return refuse(&c.out, &why),
};
// start feat: remove-from-session
if self.barred.get(&hello.file).is_some_and(|b| b.contains(&c.ip)) {
log::info!("event=refused ip={} reason=removed", c.ip);
return refuse(&c.out, BARRED);
}
// end feat: remove-from-session
let hosting = reads_hosting(&hello.client_version);
// The backend checked the secret: the sessions don't keep it.
hello.token.clear();
@@ -1362,6 +1429,36 @@ impl<B: Backend> Hub<B> {
}
}
// start feat: remove-from-session
/// Remove client `id` from `file`: they are told the session is over for them and
/// disconnected, and their address can't join the file again while the hub runs. The person
/// hosting can't be removed.
fn remove_peer(&mut self, file: &str, id: u32) -> Result<(), String> {
let room = self.rooms.get(file).ok_or("nobody is in this file")?;
if room.local.as_ref().is_some_and(|(local, _)| *local == id) {
return Err("the person hosting can't be removed".into());
}
let conn = *room.ids.get(&id).ok_or_else(|| format!("nobody with client id {id} is in this file"))?;
let c = self.conns.get(&conn).ok_or_else(|| format!("nobody with client id {id} is in this file"))?;
let barred = self.barred.entry(file.to_string()).or_default();
if barred.len() < MAX_BARRED {
barred.insert(c.ip);
}
log::info!("event=removed file={file} client={id} ip={}", c.ip);
let _ = c.out.tx.try_send(Out::Close(CloseCode::Away, REMOVED.into()));
self.gone(conn);
Ok(())
}
/// [`remove_peer`](Self::remove_peer) as a managing connection's answer.
fn removed_reply(&mut self, file: &str, id: u32) -> AdminReply {
match self.remove_peer(file, id) {
Ok(()) => AdminReply::Done,
Err(reason) => AdminReply::Failed { reason },
}
}
// end feat: remove-from-session
/// Take in connection `c` as `kind` and send it `first`.
fn take_in(&mut self, conn: u64, c: Pending, kind: Kind, first: &ServerMsg) {
self.conns.insert(conn, Conn { out: c.out, sock: c.sock, kind, ip: c.ip });
+14
View File
@@ -243,6 +243,20 @@ impl LiveLink for NetLink {
}
}
// start feat: remove-from-session
fn remove(&mut self, session: SessionId, client: u32) -> Result<(), String> {
match self.sessions.get(session) {
Some(Active::Host(h)) if h.me().client_id == client => Err("you can't remove yourself: stop the session instead".into()),
Some(Active::Host(h)) => {
h.remove(client);
Ok(())
}
Some(Active::Guest(_)) => Err("only the person hosting can remove people from this session".into()),
None => Err("this session is over".into()),
}
}
// end feat: remove-from-session
fn leave(&mut self, session: SessionId) {
if let Some(active) = self.sessions.remove(session) {
active.end();
+34 -1
View File
@@ -15,7 +15,7 @@ use tungstenite::protocol::WebSocket;
use tungstenite::{Bytes, Message};
use crate::guest::{Guest, GuestConfig};
use crate::host::{ENDED, Host, HostConfig};
use crate::host::{ENDED, Host, HostConfig, REMOVED};
use crate::inbox::Inbox;
use crate::invite::Invite;
use crate::net::{Bucket, NetLimits};
@@ -611,3 +611,36 @@ fn until_presence_or_pong(r: &mut Raw) -> Option<ServerMsg> {
}
}
}
// start feat: remove-from-session
#[test]
fn the_host_removes_someone_and_they_cant_come_back() {
let (mut host, mut hp, invite) = start();
let (_a, mut ap) = join(&invite, "Ada");
let (b, mut bp) = join(&invite, "Bo");
ap.wait("welcome", welcomed);
bp.wait("welcome", welcomed);
let (ada, bo) = (ap.me(), bp.me());
connections_settle(&host, 2);
// The host can't remove themselves, nor someone who isn't there: nothing happens.
host.remove(host.me().client_id);
host.remove(9999);
host.remove(ada);
// Ada is told the session is over for her (no reconnecting); the others see her leave.
ap.wait("Ada removed", |e| e.iter().any(|e| matches!(e, Event::Ended { reason } if reason == REMOVED)));
hp.wait("the host sees Ada leave", |e| e.iter().any(|e| matches!(e, Event::PeerLeft(id) if *id == ada)));
bp.wait("Bo sees Ada leave", |e| e.iter().any(|e| matches!(e, Event::PeerLeft(id) if *id == ada)));
connections_settle(&host, 1);
assert!(!ap.events.iter().any(|e| matches!(e, Event::Status(Status::Reconnecting { .. }))), "a removed guest doesn't reconnect");
assert!(!hp.left().contains(&bo), "only Ada was removed");
// Her address can't join again with the same invite (every guest here comes from 127.0.0.1).
let (_again, mut againp) = join(&invite, "Ada");
againp.wait("a refusal", |e| e.iter().any(|e| matches!(e, Event::Status(Status::Failed { reason }) if reason.contains("removed"))));
assert!(!welcomed(&againp.events));
// Bo, already in, stays and can still talk to the host.
b.presence(presence(3.0, 4.0));
hp.wait("Bo's cursor", |e| e.iter().any(|e| matches!(e, Event::Presence(id, _) if *id == bo)));
host.stop();
assert_eq!(host.connections(), 0);
}
// end feat: remove-from-session
+3
View File
@@ -75,6 +75,9 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
ClientMsg::Admin(Admin::Block { ip: "203.0.113.9".into() }),
ClientMsg::Admin(Admin::Unblock { ip: "2001:db8::".into() }),
ClientMsg::Admin(Admin::Blocked),
// start feat: remove-from-session
ClientMsg::Admin(Admin::RemovePeer { file: "f".into(), client: 7 }),
// end feat: remove-from-session
ClientMsg::Report { reason: "Spam ☕".into() },
];
let snapshot = Replica::new(Document::new("Snap")).snapshot().unwrap();
+6
View File
@@ -274,6 +274,12 @@ pub enum Admin {
/// these `(file id, owner token)` pairs own, at most [`MAX_OWNED`] of them:
/// [`AdminReply::Owned`]. Servers older than this request can't read it.
Owned { files: Vec<(String, String)> },
// start feat: remove-from-session
/// Remove client `client` from `file`'s session: they are disconnected and their address
/// can't join the file again while the server runs. With the server key, or on an open
/// hosting connection with `file`'s owner token. Servers older than this request can't read it.
RemovePeer { file: String, client: u32 },
// end feat: remove-from-session
}
/// The most files one [`Admin::Owned`] asks about.
+71
View File
@@ -517,6 +517,13 @@ pub trait LiveLink: Send {
fn background(&mut self, _job: u64, _req: NetRequest) -> bool {
false
}
// start feat: remove-from-session
/// Remove client `client` from session `session`, which this link hosts: they are
/// disconnected and can't join again from their address while it runs. Returns at once.
fn remove(&mut self, _session: SessionId, _client: u32) -> std::result::Result<(), String> {
Err("removing people needs the desktop app or the MCP server".into())
}
// end feat: remove-from-session
/// End session `session` (stop hosting or leave); the others go on. Nothing for a session
/// that is over already.
fn leave(&mut self, session: SessionId);
@@ -1889,6 +1896,61 @@ fn follow(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"follow": s.live().follow}))
}
// start feat: remove-from-session
/// Remove someone from the open file's session: in a session this app hosts, the hub drops them;
/// in a file of yours on a server, the server does (with the file's owner token). They are told
/// the session is over for them, keep their copy, and can't join again from their address while
/// the session (or the server) runs.
fn remove_person(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "collab.removePerson";
let client =
get(p, "client").and_then(Value::as_u64).and_then(|n| u32::try_from(n).ok()).ok_or_else(|| bad(CMD, "`client` must be a client id"))?;
let live = s.live();
if live.status != Status::Live {
return Err(bad(CMD, "the open file isn't in a live session"));
}
let name =
live.peers.get(&client).map(|p| p.name.clone()).ok_or_else(|| bad(CMD, format!("nobody with client id {client} is in the session")))?;
let (id, role, server_file) = (live.id, live.role(), live.server_file());
match (role, server_file) {
(Some(Role::Host), _) => {
let link = s.live.link.as_deref_mut().ok_or_else(|| unavailable(CMD))?;
link.remove(id, client).map_err(EngineError::Other)?;
}
(Some(Role::Guest), Some((server, file))) => {
let owner =
s.live().published.as_ref().and_then(|p| p.owner.clone()).or_else(|| s.online_owner(&server, &file)).filter(|o| !o.is_empty());
let owner = owner.ok_or_else(|| bad(CMD, "only the file's owner can remove people from it"))?;
let req = Some(Admin::RemovePeer { file: file.clone(), client });
match hosting_request(s, CMD, &server, &file, &owner, req) {
Ok((_, Some(AdminReply::Done))) => {}
Ok(_) => return Err(EngineError::Other(format!("couldn't remove {name}: the server answered something else"))),
// A server older than this request can't read it.
Err(e) => return Err(EngineError::Other(format!("couldn't remove {name}: {e} (the server may be older than removing people)"))),
}
}
_ => return Err(bad(CMD, "only the person hosting the session, or the file's owner, can remove people")),
}
s.message = Some(format!("{name} was removed from the session"));
Ok(json!({"removed": client, "name": name}))
}
/// You may remove others from the open file's session: you host it, or it is your file on a
/// server.
pub fn can_remove_people(s: &Session) -> bool {
let live = s.live();
live.status == Status::Live
&& match live.role() {
Some(Role::Host) => true,
Some(Role::Guest) => live.server_file().is_some_and(|(server, file)| {
live.published.as_ref().and_then(|p| p.owner.as_deref()).is_some_and(|o| !o.is_empty())
|| s.online_owner(&server, &file).is_some_and(|o| !o.is_empty())
}),
None => false,
}
}
// end feat: remove-from-session
fn cursor(s: &mut Session, p: &Value) -> Result<Value> {
let at = match (num(p, "x"), num(p, "y")) {
(Some(x), Some(y)) => Some((x, y)),
@@ -1997,6 +2059,15 @@ pub fn specs() -> Vec<CommandSpec> {
|s, _| Ok(s.live_summary())
),
spec!("collab.follow", "Follow", "{client? (a peer's id; omit to stop following)} → {follow}", false, follow),
// start feat: remove-from-session
spec!(
"collab.removePerson",
"Remove from session",
"{client (a peer's id)} → {removed, name}: they are disconnected (they keep their copy) and can't join again from their address while the session runs; only the person hosting, or the owner of a file on a server, can do it",
false,
remove_person
),
// end feat: remove-from-session
spec!(
"collab.cursor",
"Show cursor at",
+49
View File
@@ -28,6 +28,10 @@ pub(crate) struct Script {
/// The session each presence in `sent` and each message in `msgs` was for.
pub(crate) sent_to: Vec<u64>,
pub(crate) msgs_to: Vec<u64>,
// start feat: remove-from-session
/// The people removed: `(session, client)`.
pub(crate) removed: Vec<(u64, u32)>,
// end feat: remove-from-session
}
pub(crate) struct Mock(pub(crate) Arc<Mutex<Script>>);
@@ -62,6 +66,12 @@ impl LiveLink for Mock {
s.published.push(opts);
Ok(())
}
// start feat: remove-from-session
fn remove(&mut self, session: u64, client: u32) -> Result<(), String> {
self.0.lock().unwrap().removed.push((session, client));
Ok(())
}
// end feat: remove-from-session
fn leave(&mut self, session: u64) {
let mut s = self.0.lock().unwrap();
s.left += 1;
@@ -314,6 +324,45 @@ fn the_host_ending_resets_everything() {
assert_eq!(s.message.as_deref(), Some("The host ended the session"));
}
// start feat: remove-from-session
#[test]
fn the_host_removes_people_and_guests_cant() {
let (mut s, script) = session();
let remove = |s: &mut Session, client: serde_json::Value| s.execute("collab.removePerson", &json!({ "client": client }));
// Not in a session: nobody to remove.
assert!(matches!(remove(&mut s, json!(2)), Err(EngineError::BadParams { .. })));
assert!(!crate::live::can_remove_people(&s));
s.execute("collab.host", &json!({})).unwrap();
let id = s.live().id;
push(&script, vec![Event::PeerJoined(peer(2, "Ada")), Event::PeerJoined(peer(3, "Bo"))]);
s.live_poll();
assert!(crate::live::can_remove_people(&s));
// Only someone who is here, by a client id.
for bad in [json!(9), json!(1), json!(-2), json!("2"), json!(4_294_967_296u64)] {
assert!(matches!(remove(&mut s, bad.clone()), Err(EngineError::BadParams { .. })), "{bad}");
}
let r = remove(&mut s, json!(2)).unwrap();
assert_eq!((r["removed"].as_u64(), r["name"].as_str()), (Some(2), Some("Ada")));
assert_eq!(script.lock().unwrap().removed, [(id, 2)]);
assert_eq!(s.message.as_deref(), Some("Ada was removed from the session"));
// The host's own session tells them she left.
push(&script, vec![Event::PeerLeft(2)]);
s.live_poll();
assert_eq!(s.live().peers.keys().copied().collect::<Vec<_>>(), [3]);
s.execute("collab.stop", &json!({})).unwrap();
// A guest in someone else's session can't remove anyone.
let (mut s, script) = session();
s.execute("collab.join", &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap();
push(&script, vec![Event::Welcome { info: info(Role::Guest, 2), peers: vec![peer(1, "Hana"), peer(3, "Bo")] }]);
s.live_poll();
assert!(!crate::live::can_remove_people(&s));
let e = remove(&mut s, json!(3)).unwrap_err().to_string();
assert!(e.contains("only the person hosting"), "{e}");
assert!(script.lock().unwrap().removed.is_empty());
}
// end feat: remove-from-session
#[test]
fn collab_commands_survive_junk() {
let junk = [
+1
View File
@@ -1045,6 +1045,7 @@ name = "English"
"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."
"Remove {name} from session" = "Remove {name} from session"
"Remove {name} from {server}?" = "Remove {name} from {server}?"
"Removed" = "Removed"
"Removed from the server" = "Removed from the server"
+22 -1
View File
@@ -444,7 +444,8 @@ fn follow_frame(painter: &egui::Painter, canvas: Rect, name: &str, fill: Color32
}
/// You and the others at the top of the right panel, within `area`: click someone to follow
/// them (again to stop); the chevron lists everyone.
/// them (again to stop); right-click them to remove them from the session (when you host it or
/// own the file); the chevron lists everyone.
pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) {
let t = Tokens::get(ui.ctx());
const R: f32 = 12.0;
@@ -459,6 +460,10 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) {
let peers: Vec<(u32, String, Color32)> = live.peers.values().map(|p| (p.client_id, p.name.clone(), color(&p.color))).collect();
let follow = live.follow;
let (in_session, spotlighting) = (live.status == Status::Live, live.spotlighting);
// start feat: remove-from-session
let can_remove = figcraft_engine::live::can_remove_people(&app.session);
let mut remove = None;
// end feat: remove-from-session
// Room for the avatars after yours and the chevron.
let fit = (((area.width() - 2.0 * R - 18.0) / STEP).floor() as usize).max(1);
let (show, more) = if peers.len() > fit { (fit - 1, peers.len() - (fit - 1)) } else { (peers.len(), 0) };
@@ -496,6 +501,16 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) {
if resp.clicked() {
clicked = Some(*id);
}
// start feat: remove-from-session
if can_remove {
crate::right::context_popup(&resp).show(|ui| {
if menu_item(ui, &trf("Remove {name} from session", &[("name", name)]), "") {
remove = Some(*id);
ui.close();
}
});
}
// end feat: remove-from-session
}
if more > 0 {
x += STEP;
@@ -539,6 +554,12 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) {
if spotlight {
app.run("collab.spotlight", json!({"on": !spotlighting}));
}
// start feat: remove-from-session
// A server is asked without the window waiting for it.
if let Some(id) = remove {
app.run_background(&ui.ctx().clone(), "collab.removePerson", json!({"client": id}), Box::new(|_, _, _| {}));
}
// end feat: remove-from-session
}
/// The Share button's popover: the live session (start, invite link, stop or leave) and export.
+11 -1
View File
@@ -386,6 +386,16 @@ resuming with its offline edits, and leaving the session going back to snapshot
outlines in the person's colour, and avatars (initials) at the top of the right panel beside
Share; click an avatar to **follow** that person's viewport (`collab.follow {client}`), click
again, press Escape or pan/zoom yourself to stop. The chevron lists everyone.
<!-- start feat: remove-from-session -->
- **Removing someone.** Right-click their avatar ▸ "Remove <name> from session"
(`collab.removePerson {client}`), when you host the session or own the file on a server. The
hub closes their connection with "You were removed from this session" (close code 1001: the
session is over for them, no reconnecting) and bars their address (its `ip_key`) from that file
while it runs, so the invite they hold no longer gets them in; everyone else stays connected. On
a server the owner asks on an open hosting connection with the file's owner token
(`Admin::RemovePeer {file, client}`; the operator may too, with the server key). Addresses are
the only identity a session has: someone behind the same address (one NAT) is barred too.
<!-- end feat: remove-from-session -->
- **Text carets.** While someone types in a text layer of the shared document on your page, their
caret and selected characters show on it in their colour (the text layout's own caret and
selection geometry, so they sit at the right glyphs of your copy), with their name in a tag above
@@ -463,7 +473,7 @@ resuming with its offline edits, and leaving the session going back to snapshot
`collab.share {via?: "online" | "local", access?, …}`,
`collab.host {port?, bind?: "local" | "lan" | <ip>}`, `collab.stop`, `collab.join {code | link}`,
`collab.leave`, `collab.peers`, `collab.follow {client?}`, `collab.cursor {x?, y?}` (an agent's
pointer), `collab.cursorChat {text?}`, `collab.spotlight {on?}`.
pointer), `collab.cursorChat {text?}`, `collab.spotlight {on?}`, `collab.removePerson {client}`.
- **Sessions are per document.** Every open file has its own session or none: hosting, joined,
being published, reconnecting or failed. Several run at once (host one file, join another,
publish a third), each with its own people, links, undo history, cursor chat, spotlight and
+3 -2
View File
@@ -1,6 +1,6 @@
# Collaboration parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version
history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model,
@@ -15,7 +15,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Left: server archives beyond Linux | 25–55 |
| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors, selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 |
| Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 |
| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The list is per computer: no accounts, teams or projects | 8–17 |
| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 |
| Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences; a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) |
| Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file: restoring keeps the replaced design as an autosave first, a titled autosave becomes a named version, merging a branch records the file before and after. Versions are per person in a live session (a shared history needs accounts); no comparing two versions (Dev Mode compares a ready-for-dev layer with its state when marked) | — |
| Branching and merging | yes (paid plans) | **done, local**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived); fills, strokes and effects merge item by item. Left: branches in live sessions; other lists (text runs, export settings) conflict as a whole | 1–2 |
@@ -26,6 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | minor | Removing someone from a live session (M9, from the owner's use: whoever joined with the invite couldn't be put out short of ending the session for everyone): right-click a person's avatar ▸ "Remove <name> from session", or `collab.removePerson {client}`, when you host the session or own the file on a server (owner token). The hub closes their connection as the session's end for them (they keep their copy and don't reconnect) and bars their address from that file while the session or server runs, so the code or link they hold no longer gets them in; everyone else stays. Protocol addition within version 2 (`Admin removePeer`; older servers say they can't). Addresses are the only identity without accounts: people behind one address are barred together. Percentages and hours unchanged |
| 2026-10-13 | minor | Live-session revert guard (M9, #198): `file.revert` is refused while its document belongs to a live session, including publishing, reconnecting and hosting before the welcome. The document, previews, branch tabs and session stay intact; leave the session before reverting a local save. Offline revert is unchanged. Scripted access and sync regressions in `live_access_tests`. Numbers unchanged. |
| 2026-10-12 | minor | Your online files (M9): a file put on a server without its key stays its owner's after its tab closes or the app quits. The app records each one with the preferences (server, id, name, owner token, the owner's links, the codes others join with; at most 200; the token never in a command result, `ui.inspect`, a log or debug output) and Home ▸ **Online** lists them: open one as its owner (a new tab, or its tab comes to the front), Start / Stop sharing, Copy code / Copy link, Rename on the server, Remove from server (confirmed), Forget (the file stays on the server). Nothing is asked of a server until the section opens; then one request per server (`Admin owned`, a compatible addition within protocol version 2) says which files are still there, and a server that is offline or older leaves the list as recorded. Tabs of files in a live session carry a mark (shared, or open only to you), toasts about a file that isn't in front name it, and after a crash a recorded file reopens from its server as its owner, its recovery copy kept until its unsent changes are back on the server (or it is opened beside the file). `collab.files`, `collab.openFile`, `collab.forgetFile`; `collab.manageFile` takes its server and token from the record and gains `share` / `unshare`. Files are known per computer, not per person (no accounts): 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 |
+7
View File
@@ -1090,6 +1090,13 @@ only: a host stops hosting it, a guest leaves it.
pointer. Your selection, page and view go out by themselves when they change.
- `collab.follow {client}` follows that person's viewport; `collab.follow {}` stops (so does any
pan or zoom of your own, or Escape).
<!-- start feat: remove-from-session -->
- `collab.removePerson {client}` removes that person from the session: they are told the session
is over for them (they keep their copy and don't reconnect), and their address can't join the
file again while the session (or the server) runs. Only the person hosting, or the owner of a
file on a server (with its owner token), can; a server older than this request says it can't.
Returns `{removed, name}`.
<!-- end feat: remove-from-session -->
- `collab.cursorChat {text}` shows `text` next to your cursor for everyone in the session (put the
cursor somewhere first with `collab.cursor`): at most 100 characters, control characters become
spaces, `""` closes it. Others see it fade 5 seconds after its last change, so send it again to