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
+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 = [