Files
Filip Strajnar a2be6632eb M9: share a file on the local network read-only, with a view-only code
The session the app hosts for a local network share now has two secrets:
the edit code and a view-only code (same address and port, a 44-bit secret
of its own). Whoever joins with the view code is welcomed as a viewer: they
see every change and presence, the app makes the file read-only for them,
and the session refuses their batches, as a server does for view links.

Share > Local network offers Can edit / Can view before sharing starts (the
edit code then never shows) and while sharing. collab.share {via: "local",
access: "view"} does the same; share in collab.peers lists both codes and
links. There is still no comment code on the local network.

Next::Stop boxes its event: SessionInfo grew past clippy's
large_enum_variant threshold, and it only ever carries a status or an end.

Tests: loopback_tests::a_view_token_lets_people_look_and_not_edit,
code_tests::a_local_view_code_lets_people_look_and_not_edit,
live_share_tests::a_local_share_can_start_for_looking_only, and the local
share tests that asserted edit-only codes. Docs: collab-protocol.md,
collaboration-parity.md.
2026-11-06 17:03:21 +01:00

165 lines
6.3 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 edit token and, for a share that has one, its view 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};
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;
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,
/// A second secret that lets its holders look and not edit (`None`: everyone who gets in
/// edits). It must differ from `token`.
pub view_token: Option<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 tokens: whoever has the edit token edits, whoever has the view
/// token only looks (the server refuses their changes).
struct OneFile {
file: String,
token: String,
view: Option<String>,
}
impl Backend for OneFile {
/// Let in a hello for `file` with `token`, with what its token allows.
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());
}
// Both tokens are compared, whichever matches, so the time taken doesn't tell them apart.
let edit = same_secret(token, &self.token);
let view = self.view.as_deref().is_some_and(|v| same_secret(token, v));
match (edit, view) {
(true, _) => Ok(Admission { access: Access::Edit, link: None }),
(false, true) => Ok(Admission { access: Access::View, link: None }),
(false, false) => Err("wrong session token".into()),
}
}
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> {
// A view token equal to the edit token would let its holders edit: nobody gets in with it.
if cfg.view_token.as_deref().is_some_and(|v| same_secret(v, &cfg.token)) {
return Err(io::Error::other("the view-only secret is the same as the edit secret"));
}
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, view: cfg.view_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);
}
/// 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) {
self.hosting.cut_connections();
}
/// End the session: everyone is told and disconnected, the port is released.
pub fn stop(&mut self) {
self.hosting.stop();
}
}