mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-28 20:36:55 -06:00
Two presence features for live sessions, with no accounts needed:
- **Cursor chat.** In a live session, `/` on the canvas opens a bubble in your colour at your pointer. What you type goes to everyone as you type, and others see it next to your cursor in place of your name tag. Enter (or clicking away) sends it and closes the box. On every screen it fades a few seconds after its last change (5 s, then a 1 s fade; with reduced motion it vanishes without fading). Escape takes it back at once and doesn't deselect. A box left open without typing closes by itself. Limits: 100 characters, control characters become spaces. Agents use `collab.cursorChat {text}`.
- **Spotlight.** Your avatar (top of the right panel) now has a menu with Spotlight me / Stop spotlighting (`collab.spotlight {on?}`). Everyone else follows your view and page, using the existing follow. People who join later are told by the server and follow too. Anyone who pans, zooms or presses Escape stops following, and a canvas banner ("Hana is spotlighting", Follow) lets them follow again. A newer spotlight takes over the old one. The presenter leaving or stopping ends it. The presenter sees a banner with Stop. After a reconnect, your spotlight goes out again.
- **Protocol: signals.** New `ClientMsg::Signal {kind, text}` and `ServerMsg::Signal {client_id, signal}` carry ephemeral presence. Signals never go into the document, the log or a saved state; the server keeps only who is spotlighting, for newcomers. The server cleans each signal: unknown kinds, malformed spotlights, a client's `ready` and anything over 1 KiB are dropped, and chat is capped and has control characters replaced. The app checks what arrives again before showing it. The hub rate-limits signals per connection (20 a second, bursts of 40) and drops the excess without closing the connection.
- **Compatibility, both directions.** A client that reads signals announces it with the word `signals` in its hello's `client_version`, and the server sends signals only to those clients, so older clients never see a message they can't decode. A server that takes signals answers with a `ready` signal after the welcome, and the client sends signals only after that, so older servers never get one; the app says the host runs an older version. Signals are kind plus text, so clients skip kinds added later and servers drop them.
- Engine: `Live` keeps `signals`, `chats`, `chat`, `spotlight` and `spotlighting`, all reported by `collab.peers`. UI: chat bubbles, the chat box, the spotlight banner (the existing canvas banner widget), the avatar menu and `ui.inspect` fields. Keymap: `/` maps to `collab.cursorChat` (Essential). Spanish labels for both commands, plus en/es UI strings.
- Docs: collab-protocol (messages, compatibility, Presence, security limits, agents), mcp.md, collaboration-parity rows, target-app-parity, ROADMAP, gaps, ui-parity.
143 lines
5.0 KiB
Rust
143 lines
5.0 KiB
Rust
//! The session host the app embeds: the shared [hub](crate::hub) serving one file, the one the
|
|
//! app shares, with the session's single token. The person hosting is in it without a socket:
|
|
//! their presence goes in with [`Host::presence`], their edits with [`Host::send`], what others do
|
|
//! comes out through their [`Inbox`]. When the host stops, the session ends for everyone.
|
|
|
|
use std::io;
|
|
use std::net::{SocketAddr, TcpListener};
|
|
|
|
use figcraft_collab::message::{Access, ClientMsg, Hello, PROTOCOL, Peer, Presence, ServerMsg};
|
|
use figcraft_collab::{Outgoing, Server, same_secret};
|
|
use figcraft_doc::Document;
|
|
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;
|
|
use crate::net::NetLimits;
|
|
|
|
/// What a hosted session needs.
|
|
pub struct HostConfig {
|
|
/// The address to listen on (port 0: any free port).
|
|
pub addr: SocketAddr,
|
|
/// When `addr`'s port is taken, take any free one instead.
|
|
pub any_port_if_taken: bool,
|
|
/// The file's session id and secret (both in the invite).
|
|
pub file: String,
|
|
pub token: String,
|
|
/// The shared document (the session's authoritative copy starts from it).
|
|
pub doc: Document,
|
|
/// The host's own name (empty: "Host").
|
|
pub name: String,
|
|
pub limits: NetLimits,
|
|
pub core: figcraft_collab::Limits,
|
|
}
|
|
|
|
/// A running session host. Dropping it stops it.
|
|
pub struct Host {
|
|
hosting: Hosting,
|
|
me: Peer,
|
|
}
|
|
|
|
/// The app's one file and its token: everyone with the invite edits.
|
|
struct OneFile {
|
|
file: String,
|
|
token: String,
|
|
}
|
|
|
|
impl Backend for OneFile {
|
|
fn admit(&mut self, file: &str, token: &str) -> Result<Admission, String> {
|
|
if file != self.file {
|
|
return Err("this invite is for another file or an earlier session".into());
|
|
}
|
|
if !same_secret(token, &self.token) {
|
|
return Err("wrong session token".into());
|
|
}
|
|
Ok(Admission { access: Access::Edit, link: None })
|
|
}
|
|
|
|
fn open(&mut self, _: &str) -> Result<Server, String> {
|
|
Err("this invite is for another file or an earlier session".into())
|
|
}
|
|
|
|
fn closing(&self) -> Closing {
|
|
Closing::Ended
|
|
}
|
|
}
|
|
|
|
impl Host {
|
|
/// Listen and start the hub; the host joins its own session as the first person.
|
|
pub fn start(cfg: HostConfig, inbox: Inbox) -> io::Result<Host> {
|
|
let listener = match TcpListener::bind(cfg.addr) {
|
|
Ok(l) => l,
|
|
Err(e) if e.kind() == io::ErrorKind::AddrInUse && cfg.any_port_if_taken => TcpListener::bind(SocketAddr::new(cfg.addr.ip(), 0))?,
|
|
Err(e) => return Err(e),
|
|
};
|
|
// The hub checks the token; the session itself keeps none.
|
|
let mut server = Server::new(cfg.doc, "", cfg.core);
|
|
let name = match clean_name(&cfg.name) {
|
|
n if n.is_empty() => "Host".to_string(),
|
|
n => n,
|
|
};
|
|
let hello = Hello {
|
|
protocol: PROTOCOL,
|
|
file: cfg.file.clone(),
|
|
token: String::new(),
|
|
name,
|
|
color: PEOPLE[0].to_string(),
|
|
client_version: figcraft_collab::message::client_version(),
|
|
resume: None,
|
|
};
|
|
let (me, outs) = server.join(hello).map_err(|r| io::Error::other(format!("{r:?}")))?;
|
|
// The host's window syncs through its own client too: its welcome carries the document.
|
|
for o in outs {
|
|
match o {
|
|
Outgoing::To(id, w @ ServerMsg::Welcome { .. }) if id == me => inbox.push(Event::Sync(w)),
|
|
Outgoing::To(id, ref m @ ServerMsg::Signal { .. }) if id == me => to_local(&inbox, me, m),
|
|
_ => {}
|
|
}
|
|
}
|
|
let me = server.peers().find(|p| p.client_id == me).cloned().ok_or_else(|| io::Error::other("the host didn't join its own session"))?;
|
|
let backend = OneFile { file: cfg.file.clone(), token: cfg.token };
|
|
let local = Local { file: cfg.file, server, id: me.client_id, inbox };
|
|
let hosting = Hosting::start(listener, backend, cfg.limits, Some(local))?;
|
|
Ok(Host { hosting, me })
|
|
}
|
|
|
|
/// Where it listens.
|
|
pub fn addr(&self) -> SocketAddr {
|
|
self.hosting.addr()
|
|
}
|
|
|
|
/// The host as the others see them.
|
|
pub fn me(&self) -> &Peer {
|
|
&self.me
|
|
}
|
|
|
|
/// Open connections (joined or not).
|
|
pub fn connections(&self) -> usize {
|
|
self.hosting.connections()
|
|
}
|
|
|
|
/// The host's own presence.
|
|
pub fn presence(&self, p: Presence) {
|
|
self.hosting.presence(p);
|
|
}
|
|
|
|
/// A message from the host's own sync client (its edits).
|
|
pub fn send(&self, msg: ClientMsg) {
|
|
self.hosting.send(msg);
|
|
}
|
|
|
|
/// Cut every connection as a network failure would (no close frame).
|
|
#[cfg(test)]
|
|
pub(crate) fn cut_connections(&self) {
|
|
self.hosting.cut_connections();
|
|
}
|
|
|
|
/// End the session: everyone is told and disconnected, the port is released.
|
|
pub fn stop(&mut self) {
|
|
self.hosting.stop();
|
|
}
|
|
}
|