diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index 8dfbb708..4886bc88 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -1489,6 +1489,8 @@ 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(), + // 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()), Admin::Remove { file } => { if !self.files.contains_key(&file) { return failed("this file isn't on the server (it may have been removed)".into()); diff --git a/crates/collab-net/src/barred_tests.rs b/crates/collab-net/src/barred_tests.rs new file mode 100644 index 00000000..c9355a5f --- /dev/null +++ b/crates/collab-net/src/barred_tests.rs @@ -0,0 +1,53 @@ +//! Addresses removed from a file are kept only while its session lasts, so a long-running server +//! doesn't collect them for every file it ever had open. + +use std::collections::HashMap; +use std::net::{IpAddr, Ipv4Addr}; + +use figcraft_collab::{Limits, Server}; +use figcraft_doc::Document; + +use super::{Admission, Backend, Closing, Hub, InFlight, Room}; + +/// A backend that closes a file as soon as everyone left it. +struct Closes; + +impl Backend for Closes { + fn admit(&mut self, _file: &str, _token: &str) -> Result { + Err("no".into()) + } + + fn open(&mut self, _file: &str) -> Result { + Err("no".into()) + } + + fn idle(&mut self, _file: &str, _server: &mut Server) -> bool { + true + } + + fn closing(&self) -> Closing { + Closing::Restarting + } +} + +#[test] +fn a_closed_file_forgets_whom_it_barred() { + let mut doc = Document::new("File"); + doc.repair(); + let mut hub = Hub { + in_flight: InFlight(Default::default()), + backend: Closes, + rooms: HashMap::from([("f".to_string(), Room::new(Server::new(doc, "", Limits::default())))]), + conns: HashMap::new(), + local: None, + outbox: Vec::new(), + barred: HashMap::new(), + }; + let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 7)); + hub.barred.entry("f".into()).or_default().insert(ip); + hub.barred.entry("other".into()).or_default().insert(ip); + hub.close_if_empty("f"); + assert!(!hub.rooms.contains_key("f"), "everyone left: the file closed"); + assert!(!hub.barred.contains_key("f"), "and its barred addresses went with it"); + assert!(hub.barred.contains_key("other"), "another file's stay"); +} diff --git a/crates/collab-net/src/host.rs b/crates/collab-net/src/host.rs index 0d51735e..24be22d3 100644 --- a/crates/collab-net/src/host.rs +++ b/crates/collab-net/src/host.rs @@ -11,6 +11,7 @@ use figcraft_collab::{Outgoing, Server, same_secret}; use figcraft_doc::Document; use figcraft_engine::live::{Event, PEOPLE}; +pub use crate::hub::REMOVED; use crate::hub::{Admission, Backend, Closing, Hosting, Local, clean_name, to_local}; use crate::inbox::Inbox; pub use crate::join::ENDED; @@ -129,6 +130,12 @@ impl Host { self.hosting.send(msg); } + /// 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); + } + /// Cut every connection as a network failure would (no close frame). #[cfg(test)] pub(crate) fn cut_connections(&self) { diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index 24474211..5cf3e519 100644 --- a/crates/collab-net/src/hub.rs +++ b/crates/collab-net/src/hub.rs @@ -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,8 @@ enum HubIn { }, Local(Presence), LocalMsg(ClientMsg), + /// The person hosting removes client `id` from their file (app sessions). + Remove(u32), /// Cut every connection without a word (tests: a network failure). #[cfg(test)] Cut, @@ -461,7 +463,15 @@ impl Hosting { pub(crate) fn start(listener: TcpListener, backend: B, limits: NetLimits, local: Option) -> io::Result { 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 +561,12 @@ impl Hosting { let _ = self.hub.send(HubIn::LocalMsg(msg)); } + /// 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)); + } + /// Cut every connection as a network failure would (no close frame). #[cfg(test)] pub(crate) fn cut_connections(&self) { @@ -1108,6 +1124,16 @@ 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"; +/// 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; + /// 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 +1147,9 @@ struct Hub { local: Option, /// What the servers said, sent once the backend made what changed durable. outbox: Vec<(String, Outgoing)>, + /// Addresses ([`ip_key`]) removed from each open file: they can't join it again until its + /// session closes (the file is closed or removed; the entry goes with it). + barred: HashMap>, } impl Hub { @@ -1208,6 +1237,13 @@ impl Hub { self.client_message(&file, id, msg, None); } } + 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}"); + } + } #[cfg(test)] HubIn::Cut => { for c in self.conns.values() { @@ -1232,6 +1268,14 @@ impl Hub { self.client_message(&file, id, msg, Some(bytes)); } Some(Kind::Hosting { owns }) => match msg { + 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))); + } ClientMsg::Admin(req) => { let owns = owns.clone(); let rooms = &self.rooms; @@ -1243,6 +1287,10 @@ impl Hub { _ => self.kick(&[conn], "only publishing and managing your own files are spoken on this connection"), }, Some(Kind::Managing) => match msg { + ClientMsg::Admin(Admin::RemovePeer { file, client }) => { + let reply = self.removed_reply(&file, client); + self.answer(conn, &ServerMsg::Admin(Box::new(reply))); + } 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 +1353,10 @@ impl Hub { Ok(a) => a, Err(why) => return refuse(&c.out, &why), }; + 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); + } let hosting = reads_hosting(&hello.client_version); // The backend checked the secret: the sessions don't keep it. hello.token.clear(); @@ -1362,6 +1414,34 @@ impl Hub { } } + /// 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 its session lasts. 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 }, + } + } + /// 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 }); @@ -1479,6 +1559,7 @@ impl Hub { .collect(); // Closed without saving: the file is gone. self.rooms.remove(&file); + self.barred.remove(&file); self.kick(&users, "this file was removed from the server"); } if let Some(ip) = handled.blocked { @@ -1556,6 +1637,7 @@ impl Hub { let Some(room) = self.rooms.get_mut(file).filter(|r| r.empty()) else { return }; if self.backend.idle(file, &mut room.server) { self.rooms.remove(file); + self.barred.remove(file); } } @@ -1661,3 +1743,7 @@ pub(crate) fn to_local(inbox: &Inbox, local: u32, msg: &ServerMsg) { #[cfg(test)] #[path = "queue_tests.rs"] mod queue_tests; + +#[cfg(test)] +#[path = "barred_tests.rs"] +mod barred_tests; diff --git a/crates/collab-net/src/link.rs b/crates/collab-net/src/link.rs index 754f1529..a84395aa 100644 --- a/crates/collab-net/src/link.rs +++ b/crates/collab-net/src/link.rs @@ -243,6 +243,18 @@ impl LiveLink for NetLink { } } + 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()), + } + } + fn leave(&mut self, session: SessionId) { if let Some(active) = self.sessions.remove(session) { active.end(); diff --git a/crates/collab-net/src/loopback_tests.rs b/crates/collab-net/src/loopback_tests.rs index 814d0004..32c86bd0 100644 --- a/crates/collab-net/src/loopback_tests.rs +++ b/crates/collab-net/src/loopback_tests.rs @@ -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,34 @@ fn until_presence_or_pong(r: &mut Raw) -> Option { } } } + +#[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); +} diff --git a/crates/collab/src/codec_tests.rs b/crates/collab/src/codec_tests.rs index 84931904..9f5d12fb 100644 --- a/crates/collab/src/codec_tests.rs +++ b/crates/collab/src/codec_tests.rs @@ -75,6 +75,7 @@ fn samples() -> (Vec, Vec) { ClientMsg::Admin(Admin::Block { ip: "203.0.113.9".into() }), ClientMsg::Admin(Admin::Unblock { ip: "2001:db8::".into() }), ClientMsg::Admin(Admin::Blocked), + ClientMsg::Admin(Admin::RemovePeer { file: "f".into(), client: 7 }), ClientMsg::Report { reason: "Spam ☕".into() }, ]; let snapshot = Replica::new(Document::new("Snap")).snapshot().unwrap(); diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs index 0eb0bd8c..d035042b 100644 --- a/crates/collab/src/message.rs +++ b/crates/collab/src/message.rs @@ -274,6 +274,10 @@ 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)> }, + /// Remove client `client` from `file`'s session: they are disconnected and their address + /// can't join the file again while its session lasts. 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 }, } /// The most files one [`Admin::Owned`] asks about. diff --git a/crates/engine/src/live.rs b/crates/engine/src/live.rs index 09c5831d..4001b156 100644 --- a/crates/engine/src/live.rs +++ b/crates/engine/src/live.rs @@ -517,6 +517,11 @@ pub trait LiveLink: Send { fn background(&mut self, _job: u64, _req: NetRequest) -> bool { false } + /// 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 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 +1894,59 @@ fn follow(s: &mut Session, p: &Value) -> Result { Ok(json!({"follow": s.live().follow})) } +/// 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 { + 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, + } +} + fn cursor(s: &mut Session, p: &Value) -> Result { let at = match (num(p, "x"), num(p, "y")) { (Some(x), Some(y)) => Some((x, y)), @@ -1997,6 +2055,13 @@ pub fn specs() -> Vec { |s, _| Ok(s.live_summary()) ), spec!("collab.follow", "Follow", "{client? (a peer's id; omit to stop following)} → {follow}", false, follow), + 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 + ), spec!( "collab.cursor", "Show cursor at", diff --git a/crates/engine/src/live_tests.rs b/crates/engine/src/live_tests.rs index 6b05199b..cacc4f37 100644 --- a/crates/engine/src/live_tests.rs +++ b/crates/engine/src/live_tests.rs @@ -28,6 +28,8 @@ pub(crate) struct Script { /// The session each presence in `sent` and each message in `msgs` was for. pub(crate) sent_to: Vec, pub(crate) msgs_to: Vec, + /// The people removed: `(session, client)`. + pub(crate) removed: Vec<(u64, u32)>, } pub(crate) struct Mock(pub(crate) Arc>); @@ -62,6 +64,10 @@ impl LiveLink for Mock { s.published.push(opts); Ok(()) } + fn remove(&mut self, session: u64, client: u32) -> Result<(), String> { + self.0.lock().unwrap().removed.push((session, client)); + Ok(()) + } fn leave(&mut self, session: u64) { let mut s = self.0.lock().unwrap(); s.left += 1; @@ -314,6 +320,43 @@ fn the_host_ending_resets_everything() { assert_eq!(s.message.as_deref(), Some("The host ended the 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::>(), [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()); +} + #[test] fn collab_commands_survive_junk() { let junk = [ diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index d9641b85..3a94bd1b 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -999,6 +999,7 @@ status = "draft — needs native review" "Remove the else and its actions" = "„Sonst“ und seine Aktionen entfernen" "Remove this condition and its actions" = "Diese Bedingung und ihre Aktionen entfernen" "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Diese Datei vom Server entfernen? Sie kann von dort nicht mehr geteilt oder geöffnet werden. Ihre Kopie bleibt hier geöffnet." +"Remove {name} from session" = "{name} aus der Sitzung entfernen" "Remove {name} from {server}?" = "{name} von {server} entfernen?" "Removed" = "Entfernt" "Removed from the server" = "Vom Server entfernt" @@ -1571,6 +1572,7 @@ status = "draft — needs native review" "collab.publish" = "Auf Server veröffentlichen" "collab.recoverFile" = "Online-Datei wiederherstellen" "collab.registerLinks" = "Einladungslinks in FigCraft öffnen" +"collab.removePerson" = "Aus der Sitzung entfernen" "collab.report" = "Datei dem Serverbetreiber melden" "collab.serverInfo" = "Serverinformationen" "collab.share" = "Mit Beitrittscode teilen" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 73c41dcf..83fb0c19 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1047,6 +1047,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" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 245284dd..c3b6c213 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1048,6 +1048,7 @@ status = "draft — needs native review" "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í." +"Remove {name} from session" = "Quitar a {name} de la sesión" "Remove {name} from {server}?" = "¿Quitar {name} de {server}?" "Removed" = "Eliminado" "Removed from the server" = "Quitado del servidor" @@ -1646,6 +1647,7 @@ status = "draft — needs native review" "collab.publish" = "Publicar en el servidor" "collab.recoverFile" = "Recuperar archivo en línea" "collab.registerLinks" = "Abrir enlaces de invitación en FigCraft" +"collab.removePerson" = "Quitar de la sesión" "collab.report" = "Denunciar archivo al operador del servidor" "collab.serverInfo" = "Información del servidor" "collab.share" = "Compartir con un código para unirse" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index efb13973..725a36a9 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -999,6 +999,7 @@ status = "draft — needs native review" "Remove the else and its actions" = "Retirer le « sinon » et ses actions" "Remove this condition and its actions" = "Retirer cette condition et ses actions" "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Retirer ce fichier du serveur ? Il ne pourra plus être partagé ni ouvert depuis celui-ci. Votre copie reste ouverte ici." +"Remove {name} from session" = "Retirer {name} de la session" "Remove {name} from {server}?" = "Retirer {name} de {server} ?" "Removed" = "Supprimé" "Removed from the server" = "Retiré du serveur" @@ -1571,6 +1572,7 @@ status = "draft — needs native review" "collab.publish" = "Publier sur un serveur" "collab.recoverFile" = "Récupérer un fichier en ligne" "collab.registerLinks" = "Ouvrir les liens d’invitation dans FigCraft" +"collab.removePerson" = "Retirer de la session" "collab.report" = "Signaler le fichier à l’opérateur du serveur" "collab.serverInfo" = "Informations sur le serveur" "collab.share" = "Partager avec un code d’accès" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 8c01d3d9..a3bdf355 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -1050,6 +1050,7 @@ status = "draft — needs native review" "Remove the else 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 {name} from session" = "{name} をセッションから削除" "Remove {name} from {server}?" = "{name} を {server} から削除しますか?" "Removed" = "削除" "Removed from the server" = "サーバーから削除済み" @@ -1648,6 +1649,7 @@ status = "draft — needs native review" "collab.publish" = "サーバーに公開" "collab.recoverFile" = "オンラインファイルを復元" "collab.registerLinks" = "招待リンクを FigCraft で開く" +"collab.removePerson" = "セッションから削除" "collab.report" = "ファイルをサーバーの運営者に報告" "collab.serverInfo" = "サーバー情報" "collab.share" = "参加コードで共有" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 7d0c415e..df81275a 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -1000,6 +1000,7 @@ status = "draft — needs native review" "Remove the else 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 {name} from session" = "{name} 님을 세션에서 제거" "Remove {name} from {server}?" = "{name}을(를) {server}에서 제거할까요?" "Removed" = "제거됨" "Removed from the server" = "서버에서 제거됨" @@ -1572,6 +1573,7 @@ status = "draft — needs native review" "collab.publish" = "서버에 게시" "collab.recoverFile" = "온라인 파일 복구" "collab.registerLinks" = "초대 링크를 FigCraft에서 열기" +"collab.removePerson" = "세션에서 제거" "collab.report" = "서버 운영자에게 파일 신고" "collab.serverInfo" = "서버 정보" "collab.share" = "참여 코드로 공유" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 0bbbc29f..175f74d8 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -998,6 +998,7 @@ status = "draft — needs native review" "Remove the else and its actions" = "Remover o \"senão\" e suas ações" "Remove this condition and its actions" = "Remover esta condição e suas ações" "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Remover este arquivo do servidor? Ele não poderá mais ser compartilhado nem aberto a partir de lá. Sua cópia continua aberta aqui." +"Remove {name} from session" = "Remover {name} da sessão" "Remove {name} from {server}?" = "Remover {name} de {server}?" "Removed" = "Removido" "Removed from the server" = "Removido do servidor" @@ -1570,6 +1571,7 @@ status = "draft — needs native review" "collab.publish" = "Publicar no servidor" "collab.recoverFile" = "Recuperar arquivo online" "collab.registerLinks" = "Abrir links de convite no FigCraft" +"collab.removePerson" = "Remover da sessão" "collab.report" = "Denunciar arquivo ao operador do servidor" "collab.serverInfo" = "Informações do servidor" "collab.share" = "Compartilhar com um código de acesso" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 993f1c57..c7860048 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -1001,6 +1001,7 @@ status = "draft — needs native review" "Remove the else 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 {name} from session" = "将 {name} 移出会话" "Remove {name} from {server}?" = "要从 {server} 移除 {name} 吗?" "Removed" = "已删除" "Removed from the server" = "已从服务器移除" @@ -1573,6 +1574,7 @@ status = "draft — needs native review" "collab.publish" = "发布到服务器" "collab.recoverFile" = "恢复在线文件" "collab.registerLinks" = "在 FigCraft 中打开邀请链接" +"collab.removePerson" = "移出会话" "collab.report" = "向服务器运营者举报文件" "collab.serverInfo" = "服务器信息" "collab.share" = "用加入代码分享" diff --git a/crates/ui-egui/src/live.rs b/crates/ui-egui/src/live.rs index 46687a68..5cd5f723 100644 --- a/crates/ui-egui/src/live.rs +++ b/crates/ui-egui/src/live.rs @@ -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,8 @@ 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); + let can_remove = figcraft_engine::live::can_remove_people(&app.session); + let mut remove = None; // 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 +499,14 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { if resp.clicked() { clicked = Some(*id); } + 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(); + } + }); + } } if more > 0 { x += STEP; @@ -539,6 +550,10 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { if spotlight { app.run("collab.spotlight", json!({"on": !spotlighting})); } + // 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(|_, _, _| {})); + } } /// The Share button's popover: the live session (start, invite link, stop or leave) and export. diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md index 874988a3..38ef96ec 100644 --- a/docs/collab-protocol.md +++ b/docs/collab-protocol.md @@ -386,6 +386,14 @@ 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. +- **Removing someone.** Right-click their avatar ▸ "Remove 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. - **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 +471,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" | }`, `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 diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index e7eb468f..95084d74 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -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 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 | diff --git a/docs/mcp.md b/docs/mcp.md index 2bfc6106..6e69384c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1092,6 +1092,11 @@ 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). +- `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 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}`. - `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