mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-08 03:40:28 +00:00
Merge pull request #2052 from StrajnarFilip/claude/lan-share-read-only
M9: share a file on the local network read-only, with a view-only code
This commit is contained in:
@@ -77,6 +77,7 @@ fn root(name: &str) -> PathBuf {
|
||||
r
|
||||
}
|
||||
|
||||
/// What the scripted link says about the session it is in.
|
||||
fn info() -> SessionInfo {
|
||||
SessionInfo {
|
||||
role: Role::Guest,
|
||||
@@ -90,6 +91,7 @@ fn info() -> SessionInfo {
|
||||
address: SERVER.into(),
|
||||
network: true,
|
||||
access: Access::Edit,
|
||||
view: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! Join codes over the network: a file shared on the local network joined by its code (two
|
||||
//! engine sessions over loopback), what the browser's driver does with an online code, a server
|
||||
//! Join codes over the network: a file shared on the local network joined by its code, and by
|
||||
//! its view-only code to look without editing (engine sessions over loopback), what the browser's driver does with an online code, a server
|
||||
//! older than join codes, and the links that name a code. (A `figcraft-server` handing codes out
|
||||
//! and resolving them is tested in its own crate, with the same link.)
|
||||
|
||||
use std::net::{Ipv4Addr, TcpListener};
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -18,8 +18,9 @@ use crate::driver::{Driver, Socket};
|
||||
use crate::guest::GuestConfig;
|
||||
use crate::inbox::Inbox;
|
||||
use crate::invite::{Endpoint, Invite, Lookup};
|
||||
use crate::link::reachable;
|
||||
use crate::loopback_tests::{fast_limits, quick};
|
||||
use crate::{NetLink, manage};
|
||||
use crate::{NetLink, lan_ip, manage};
|
||||
|
||||
fn session(name: &str) -> Session {
|
||||
let mut s = Session::new();
|
||||
@@ -53,6 +54,7 @@ fn layers(s: &Session) -> usize {
|
||||
s.doc().map_or(0, |d| d.node_count())
|
||||
}
|
||||
|
||||
/// A local network share's code joins its session from another app, and edits sync both ways.
|
||||
#[test]
|
||||
fn a_local_share_is_joined_by_its_code_and_edits_sync() {
|
||||
let (mut host, mut guest) = (session("Hana"), session("Ada"));
|
||||
@@ -65,7 +67,13 @@ fn a_local_share_is_joined_by_its_code_and_edits_sync() {
|
||||
assert_eq!(local.ip, Ipv4Addr::LOCALHOST);
|
||||
assert_eq!(shared["address"], format!("127.0.0.1:{}", local.port));
|
||||
assert_eq!(shared["link"], local.link(), "the code and the invite link name the same session");
|
||||
assert!(shared["share"]["viewCode"].is_null(), "everyone edits on the local network");
|
||||
// A view-only code too, with its own secret, and no comment code.
|
||||
let view = shared["share"]["viewCode"].as_str().unwrap();
|
||||
let Code::Local(viewing) = code::parse(view).unwrap() else { panic!("not a local code") };
|
||||
assert_eq!((viewing.ip, viewing.port), (local.ip, local.port), "the same session");
|
||||
assert_ne!(viewing.secret, local.secret, "a secret of its own");
|
||||
assert_eq!(shared["share"]["viewLink"], viewing.link());
|
||||
assert!(shared["share"]["commentCode"].is_null());
|
||||
// Typed the way people type: lower case, spaces for the dashes.
|
||||
let typed = shown.to_lowercase().replace('-', " ");
|
||||
assert_eq!(guest.execute("collab.join", &json!({"code": typed})).unwrap()["status"], "connecting");
|
||||
@@ -93,6 +101,8 @@ fn a_local_share_is_joined_by_its_code_and_edits_sync() {
|
||||
assert!(label.contains("Couldn't join") || label.contains("The host refused"), "{label}");
|
||||
}
|
||||
|
||||
/// A well-formed code with the wrong secret is refused by the host, a mistyped one never connects,
|
||||
/// and there is no comment code on the local network.
|
||||
#[test]
|
||||
fn a_local_code_with_the_wrong_secret_or_a_typo_doesnt_join() {
|
||||
let (mut host, mut guest) = (session("Hana"), session("Ada"));
|
||||
@@ -112,11 +122,120 @@ fn a_local_code_with_the_wrong_secret_or_a_typo_doesnt_join() {
|
||||
*last = if *last == 'A' { 'B' } else { 'A' };
|
||||
let e = guest.execute("collab.join", &json!({"code": typo.iter().collect::<String>()})).unwrap_err().to_string();
|
||||
assert!(e.contains("typing mistake"), "{e}");
|
||||
// A view-only code doesn't exist here, and says where it does.
|
||||
let e = host.execute("collab.share", &json!({"access": "view"})).unwrap_err().to_string();
|
||||
// A comment code doesn't exist here, and says where it does.
|
||||
let e = host.execute("collab.share", &json!({"access": "comment"})).unwrap_err().to_string();
|
||||
assert!(e.contains("share it online"), "{e}");
|
||||
}
|
||||
|
||||
/// A local network share started for looking only shows its view code: whoever joins with it sees
|
||||
/// every change and can't make one, the edit code (picked later) still edits, and stopping ends
|
||||
/// both.
|
||||
#[test]
|
||||
fn a_local_view_code_lets_people_look_and_not_edit() {
|
||||
let (mut host, mut viewer, mut editor) = (session("Hana"), session("Ada"), session("Bo"));
|
||||
let shared = host.execute("collab.share", &json!({"via": "local", "bind": "local", "access": "view"})).unwrap();
|
||||
assert_eq!(shared["share"]["access"], "view");
|
||||
let (shown, edit) = (shared["code"].as_str().unwrap().to_string(), shared["share"]["editCode"].as_str().unwrap().to_string());
|
||||
assert_eq!(shared["share"]["viewCode"], shown, "the code shown is the view code");
|
||||
assert_ne!(shown, edit);
|
||||
viewer.execute("collab.join", &json!({"code": shown})).unwrap();
|
||||
pump(&mut [&mut host, &mut viewer], "the viewer's join", |s| live(s[1]) && s[0].live().peers.len() == 1);
|
||||
let summary = viewer.live_summary();
|
||||
assert_eq!((&summary["access"], &summary["readOnly"]), (&json!("view"), &json!(true)));
|
||||
// The host's edit reaches the viewer; the viewer's is refused before it leaves.
|
||||
let before = layers(&host);
|
||||
host.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap();
|
||||
pump(&mut [&mut host, &mut viewer], "the host's edit", |s| layers(s[1]) == before + 1);
|
||||
let e = viewer.execute("shape.ellipse", &json!({"x": 80, "y": 10, "width": 40, "height": 30})).unwrap_err().to_string();
|
||||
assert!(e.contains("View only"), "{e}");
|
||||
// The edit code, picked now, still edits, and the viewer sees it.
|
||||
assert_eq!(host.execute("collab.share", &json!({"access": "edit"})).unwrap()["code"], edit);
|
||||
editor.execute("collab.join", &json!({"code": edit})).unwrap();
|
||||
pump(&mut [&mut host, &mut viewer, &mut editor], "the editor's join", |s| live(s[2]) && s[0].live().peers.len() == 2);
|
||||
assert_eq!(editor.live_summary()["access"], "edit");
|
||||
editor.execute("shape.ellipse", &json!({"x": 80, "y": 10, "width": 40, "height": 30})).unwrap();
|
||||
pump(&mut [&mut host, &mut viewer, &mut editor], "the editor's edit", |s| layers(s[0]) == before + 2 && layers(s[1]) == before + 2);
|
||||
assert_eq!(viewer.live_summary()["readOnly"], true, "picking the edit code changes nothing for who joined to look");
|
||||
assert_eq!(host.execute("collab.stop", &json!({})).unwrap()["ended"], true);
|
||||
pump(&mut [&mut host, &mut viewer, &mut editor], "the end", |s| !s[1].live().status.active() && !s[2].live().status.active());
|
||||
}
|
||||
|
||||
/// A local network share's `link` and `url` are the ones that go with the code shown: started for
|
||||
/// looking only, whoever joins through either can't edit, and the session's line in `sessions`
|
||||
/// says the same.
|
||||
#[test]
|
||||
fn a_local_view_share_s_link_and_url_only_let_people_look() {
|
||||
let mut host = session("Hana");
|
||||
let shared = host.execute("collab.share", &json!({"via": "local", "bind": "local", "access": "view"})).unwrap();
|
||||
for key in ["link", "url"] {
|
||||
let way = shared[key].as_str().unwrap().to_string();
|
||||
let mut viewer = session("Ada");
|
||||
viewer.execute("collab.join", &json!({"link": way})).unwrap();
|
||||
pump(&mut [&mut host, &mut viewer], "the join", |s| live(s[1]) && s[0].live().peers.len() == 1);
|
||||
let summary = viewer.live_summary();
|
||||
assert_eq!((&summary["access"], &summary["readOnly"]), (&json!("view"), &json!(true)), "joined with {key} {way}");
|
||||
let before = layers(&host);
|
||||
let e = viewer.execute("shape.ellipse", &json!({"x": 80, "y": 10, "width": 40, "height": 30})).unwrap_err().to_string();
|
||||
assert!(e.contains("View only"), "{key}: {e}");
|
||||
host.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap();
|
||||
pump(&mut [&mut host, &mut viewer], "the host's edit", |s| layers(s[1]) == before + 1);
|
||||
assert_eq!(layers(&host), before + 1, "nothing of the viewer's reached the host ({key})");
|
||||
viewer.execute("collab.leave", &json!({})).unwrap();
|
||||
pump(&mut [&mut host, &mut viewer], "the leave", |s| s[0].live().peers.is_empty());
|
||||
}
|
||||
// The link, the URL and the code name the same way in, wherever the session is listed.
|
||||
let Code::Local(local) = code::parse(shared["code"].as_str().unwrap()).unwrap() else { panic!("not a local code") };
|
||||
assert_eq!((&shared["link"], &shared["share"]["viewLink"]), (&json!(local.link()), &json!(local.link())));
|
||||
let url = shared["url"].as_str().unwrap();
|
||||
assert!(url.starts_with("ws://") && url.ends_with(&format!("&token={}", local.token())), "{url}");
|
||||
assert_eq!(shared["sessions"][0]["link"], shared["link"]);
|
||||
// Picking the edit code makes them the edit ones too.
|
||||
let edit = host.execute("collab.share", &json!({"access": "edit"})).unwrap();
|
||||
assert_eq!((&edit["link"], &edit["sessions"][0]["link"]), (&edit["share"]["editLink"], &edit["share"]["editLink"]));
|
||||
let edit_token = edit["link"].as_str().unwrap().rsplit("token=").next().unwrap();
|
||||
assert!(edit["url"].as_str().unwrap().ends_with(&format!("&token={edit_token}")), "{}", edit["url"]);
|
||||
assert_ne!((&edit["link"], &edit["url"]), (&shared["link"], &shared["url"]));
|
||||
assert_eq!(host.execute("collab.stop", &json!({})).unwrap()["ended"], true);
|
||||
}
|
||||
|
||||
/// A share listening on every interface (`bind` 0.0.0.0) names where others reach it, never
|
||||
/// 0.0.0.0: this machine's local network address (loopback without one) in its codes, links, URL
|
||||
/// and address, and its view code and edit link both join it.
|
||||
#[test]
|
||||
fn a_share_on_every_interface_names_an_address_others_reach() {
|
||||
let (mut host, mut viewer, mut editor) = (session("Hana"), session("Ada"), session("Bo"));
|
||||
let shared = host.execute("collab.share", &json!({"via": "local", "bind": "0.0.0.0", "access": "view"})).unwrap();
|
||||
let Code::Local(viewing) = code::parse(shared["code"].as_str().unwrap()).unwrap() else { panic!("not a local code") };
|
||||
let at = lan_ip().unwrap_or(Ipv4Addr::LOCALHOST.into());
|
||||
assert_eq!(IpAddr::from(viewing.ip), at);
|
||||
let address = format!("{at}:{}", viewing.port);
|
||||
assert_eq!(shared["address"], address);
|
||||
let Code::Local(editing) = code::parse(shared["share"]["editCode"].as_str().unwrap()).unwrap() else { panic!("not a local code") };
|
||||
assert_eq!((editing.ip, editing.port), (viewing.ip, viewing.port));
|
||||
for way in [&shared["link"], &shared["url"], &shared["share"]["viewLink"], &shared["share"]["editLink"], &shared["sessions"][0]["link"]] {
|
||||
let way = way.as_str().unwrap_or_default();
|
||||
assert!(way.contains(&address) && !way.contains("0.0.0.0"), "{way}");
|
||||
}
|
||||
viewer.execute("collab.join", &json!({"code": shared["code"]})).unwrap();
|
||||
editor.execute("collab.join", &json!({"link": shared["share"]["editLink"]})).unwrap();
|
||||
pump(&mut [&mut host, &mut viewer, &mut editor], "the joins", |s| live(s[1]) && live(s[2]) && s[0].live().peers.len() == 2);
|
||||
assert_eq!((&viewer.live_summary()["access"], &editor.live_summary()["access"]), (&json!("view"), &json!("edit")));
|
||||
assert_eq!(host.execute("collab.stop", &json!({})).unwrap()["ended"], true);
|
||||
}
|
||||
|
||||
/// A listener on one address is reached at it; one on every interface at an address of its own
|
||||
/// family, on its port.
|
||||
#[test]
|
||||
fn a_listener_on_every_interface_is_reached_at_an_address_of_its_family() {
|
||||
let one: SocketAddr = "192.168.1.20:47900".parse().unwrap();
|
||||
assert_eq!(reachable(one), one);
|
||||
for any in ["0.0.0.0:47900", "[::]:47901"] {
|
||||
let any: SocketAddr = any.parse().unwrap();
|
||||
let at = reachable(any);
|
||||
assert!(!at.ip().is_unspecified() && at.is_ipv4() == any.is_ipv4() && at.port() == any.port(), "{any}: {at}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An invite naming only `server` (what a join code is asked at).
|
||||
fn code_at(server: &Endpoint) -> Invite {
|
||||
Invite { server: server.clone(), file: String::new(), token: String::new() }
|
||||
|
||||
@@ -391,7 +391,7 @@ impl<S: Socket> Driver<S> {
|
||||
match after(outcome, &self.cfg, self.was_live, &mut self.attempt, &self.inbox) {
|
||||
Next::Stop(e) => {
|
||||
if let Some(e) = e {
|
||||
self.inbox.push(e);
|
||||
self.inbox.push(*e);
|
||||
}
|
||||
self.done = true;
|
||||
}
|
||||
|
||||
@@ -419,6 +419,8 @@ fn pump(d: &mut Driver<Loopback>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The browser's driver joins a real host over loopback, edits with it, sends presence and hears
|
||||
/// the session end.
|
||||
#[test]
|
||||
fn joins_a_real_host_and_edits_with_it() {
|
||||
let host_probe = Inbox::new(Arc::new(|| {}));
|
||||
@@ -428,6 +430,7 @@ fn joins_a_real_host_and_edits_with_it() {
|
||||
any_port_if_taken: false,
|
||||
file: FILE.into(),
|
||||
token: TOKEN.into(),
|
||||
view_token: None,
|
||||
doc: shared(),
|
||||
name: "Hana".into(),
|
||||
limits: fast_limits(),
|
||||
|
||||
@@ -67,7 +67,7 @@ impl Guest {
|
||||
});
|
||||
if let Err(o) = asked.and_then(|(file, token)| resolved(&mut cfg, file, token)) {
|
||||
if let Next::Stop(Some(e)) = after(unresolved(&cfg, o), &cfg, false, &mut 0, &inbox) {
|
||||
inbox.push(e);
|
||||
inbox.push(*e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -217,7 +217,7 @@ fn supervise(cfg: &GuestConfig, inbox: &Inbox, rx: &Receiver<Cmd>, me: &Sender<C
|
||||
match after(outcome, cfg, was_live, &mut attempt, inbox) {
|
||||
Next::Stop(e) => {
|
||||
if let Some(e) = e {
|
||||
inbox.push(e);
|
||||
inbox.push(*e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! 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.
|
||||
//! 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};
|
||||
@@ -26,6 +27,9 @@ pub struct HostConfig {
|
||||
/// 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").
|
||||
@@ -40,21 +44,28 @@ pub struct Host {
|
||||
me: Peer,
|
||||
}
|
||||
|
||||
/// The app's one file and its token: everyone with the invite edits.
|
||||
/// 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());
|
||||
}
|
||||
if !same_secret(token, &self.token) {
|
||||
return Err("wrong session token".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()),
|
||||
}
|
||||
Ok(Admission { access: Access::Edit, link: None })
|
||||
}
|
||||
|
||||
fn open(&mut self, _: &str) -> Result<Server, String> {
|
||||
@@ -69,6 +80,10 @@ impl Backend for OneFile {
|
||||
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))?,
|
||||
@@ -99,7 +114,7 @@ impl Host {
|
||||
}
|
||||
}
|
||||
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 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 })
|
||||
|
||||
@@ -137,8 +137,9 @@ pub(crate) fn closed(code: u16, reason: &str) -> Outcome {
|
||||
|
||||
/// What a guest does after a connection ended.
|
||||
pub(crate) enum Next {
|
||||
/// Done: tell the UI this (nothing when you left).
|
||||
Stop(Option<Event>),
|
||||
/// Done: tell the UI this (nothing when you left). Boxed: an event can be large (a welcome),
|
||||
/// and this one is only ever a status or the end of the session.
|
||||
Stop(Option<Box<Event>>),
|
||||
/// Connect again after this pause (the UI has been told: `Reconnecting`).
|
||||
Reconnect(Duration),
|
||||
}
|
||||
@@ -147,12 +148,12 @@ pub(crate) enum Next {
|
||||
/// can't reach the host; a live connection that dropped tries again with growing pauses until the
|
||||
/// attempts run out.
|
||||
pub(crate) fn after(outcome: Outcome, cfg: &GuestConfig, was_live: bool, attempt: &mut u32, inbox: &Inbox) -> Next {
|
||||
let failed = |reason: String| Next::Stop(Some(Event::Status(Status::Failed { reason })));
|
||||
let failed = |reason: String| Next::Stop(Some(Box::new(Event::Status(Status::Failed { reason }))));
|
||||
match outcome {
|
||||
Outcome::Left => Next::Stop(None),
|
||||
Outcome::Refused(why) => failed(format!("The host refused: {why}")),
|
||||
Outcome::Failed(why) => failed(why),
|
||||
Outcome::Ended(reason) => Next::Stop(Some(Event::Ended { reason })),
|
||||
Outcome::Ended(reason) => Next::Stop(Some(Box::new(Event::Ended { reason }))),
|
||||
Outcome::Retry(why) if !was_live => failed(format!("Couldn't join the live session: {why}")),
|
||||
Outcome::Retry(why) => {
|
||||
*attempt += 1;
|
||||
@@ -228,6 +229,7 @@ pub(crate) fn take(mut m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mu
|
||||
address: inv.server.display(),
|
||||
network: !inv.loopback(),
|
||||
access,
|
||||
view: None,
|
||||
};
|
||||
seen.peers = peers.clone();
|
||||
seen.info = Some(info.clone());
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! [`NetLink`]: the engine's [`LiveLink`] over the network.
|
||||
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket};
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, UdpSocket};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
@@ -9,7 +9,7 @@ use figcraft_collab::code;
|
||||
use figcraft_collab::message::{Access, Admin, AdminReply, ClientMsg, LibraryReply, LibraryReq, PluginReply, PluginReq};
|
||||
use figcraft_engine::live::{
|
||||
Bind, Event, HostInfo, HostOptions, LiveLink, NO_SESSION, NetReply, NetRequest, PageInfo, Presence, PublishOptions, Published, Resume, Role,
|
||||
SessionId, SessionInfo, Shared,
|
||||
SessionId, SessionInfo, Shared, WayIn,
|
||||
};
|
||||
|
||||
use crate::guest::{Guest, GuestConfig, Publish, upload_file};
|
||||
@@ -97,24 +97,62 @@ impl NetLink {
|
||||
}
|
||||
}
|
||||
|
||||
/// A new secret for a hosted session: one a join code can carry (`short`: 44 bits, see
|
||||
/// `figcraft_collab::code`), else a full one.
|
||||
fn session_secret(short: bool) -> Result<String, String> {
|
||||
match short {
|
||||
true => Ok(code::Local::new(Ipv4Addr::UNSPECIFIED, 0, token::random()?).token()),
|
||||
false => token::secret(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A view-only secret for a session whose edit secret is `edit`: of the same kind, never the same.
|
||||
fn view_secret(short: bool, edit: &str) -> Result<String, String> {
|
||||
for _ in 0..3 {
|
||||
let view = session_secret(short)?;
|
||||
if view != edit {
|
||||
return Ok(view);
|
||||
}
|
||||
}
|
||||
Err("couldn't make a view-only secret".into())
|
||||
}
|
||||
|
||||
/// This machine's address on the local network: the interface the default route leaves by (no
|
||||
/// packet is sent).
|
||||
pub fn lan_ip() -> Option<IpAddr> {
|
||||
let sock = UdpSocket::bind(SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0))).ok()?;
|
||||
// Documentation and private ranges: only the route matters.
|
||||
for probe in ["192.0.2.1:9", "10.255.255.255:9"] {
|
||||
if sock.connect(probe).is_ok()
|
||||
&& let Ok(a) = sock.local_addr()
|
||||
&& !a.ip().is_loopback()
|
||||
&& !a.ip().is_unspecified()
|
||||
{
|
||||
return Some(a.ip());
|
||||
}
|
||||
}
|
||||
None
|
||||
route_ip(Ipv4Addr::UNSPECIFIED.into(), &["192.0.2.1:9", "10.255.255.255:9"])
|
||||
}
|
||||
|
||||
/// The address of `any`'s family that the route to the first of `probes` that has one leaves by
|
||||
/// (no packet is sent): never a loopback, unspecified or IPv6 link-local one, which others can't
|
||||
/// reach this machine at.
|
||||
fn route_ip(any: IpAddr, probes: &[&str]) -> Option<IpAddr> {
|
||||
let sock = UdpSocket::bind(SocketAddr::new(any, 0)).ok()?;
|
||||
probes.iter().find_map(|probe| {
|
||||
sock.connect(probe).ok()?;
|
||||
let ip = sock.local_addr().ok()?.ip();
|
||||
let link_local = matches!(ip, IpAddr::V6(v6) if v6.is_unicast_link_local());
|
||||
(!ip.is_loopback() && !ip.is_unspecified() && !link_local).then_some(ip)
|
||||
})
|
||||
}
|
||||
|
||||
/// Where others reach a session listening on `addr`: `addr` itself, or for a listener on every
|
||||
/// interface (`0.0.0.0`, `::`) this machine's local network address of the same family (its
|
||||
/// loopback without one), on the same port. Links name it; the listener keeps every interface.
|
||||
pub(crate) fn reachable(addr: SocketAddr) -> SocketAddr {
|
||||
let ip = match addr.ip() {
|
||||
ip if !ip.is_unspecified() => ip,
|
||||
IpAddr::V4(_) => lan_ip().unwrap_or(Ipv4Addr::LOCALHOST.into()),
|
||||
// Documentation and unique local ranges, as for IPv4.
|
||||
IpAddr::V6(_) => route_ip(Ipv6Addr::UNSPECIFIED.into(), &["[2001:db8::1]:9", "[fd00::1]:9"]).unwrap_or(Ipv6Addr::LOCALHOST.into()),
|
||||
};
|
||||
SocketAddr::new(ip, addr.port())
|
||||
}
|
||||
|
||||
impl LiveLink for NetLink {
|
||||
/// Host the open file on `o.bind`: a join code's short secret when `o.short`, and a second,
|
||||
/// view-only secret with its invite link when `o.view`.
|
||||
fn host(&mut self, session: SessionId, o: HostOptions) -> Result<SessionInfo, String> {
|
||||
let ip = match o.bind {
|
||||
Bind::Local => IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
@@ -123,11 +161,10 @@ impl LiveLink for NetLink {
|
||||
};
|
||||
// A session shared with a join code: its secret is short enough to be typed with the
|
||||
// address (see `figcraft_collab::code`); the code and the invite link name the same session.
|
||||
let (file, token) = if o.short {
|
||||
(code::LOCAL_FILE.to_string(), code::Local::new(Ipv4Addr::UNSPECIFIED, 0, token::random()?).token())
|
||||
} else {
|
||||
(token::file_id()?, token::secret()?)
|
||||
};
|
||||
let file = if o.short { code::LOCAL_FILE.to_string() } else { token::file_id()? };
|
||||
let token = session_secret(o.short)?;
|
||||
// A way in for looking only: a second secret of the same kind, never the edit one.
|
||||
let view_token = o.view.then(|| view_secret(o.short, &token)).transpose()?;
|
||||
let inbox = self.sessions.inbox();
|
||||
let doc = (*o.doc).clone();
|
||||
let pages = doc.pages.iter().map(|p| PageInfo { id: p.0, name: doc.node(*p).map(|n| n.name.clone()).unwrap_or_default() }).collect();
|
||||
@@ -137,13 +174,17 @@ impl LiveLink for NetLink {
|
||||
any_port_if_taken: o.port.is_none(),
|
||||
file: file.clone(),
|
||||
token: token.clone(),
|
||||
view_token: view_token.clone(),
|
||||
doc,
|
||||
name: o.name,
|
||||
limits: self.limits,
|
||||
core: figcraft_collab::Limits::default(),
|
||||
};
|
||||
let host = Host::start(cfg, inbox.clone()).map_err(|e| format!("can't listen on {ip}: {e}"))?;
|
||||
let invite = Invite::new(host.addr().to_string(), file, token);
|
||||
// Listening on every interface (`0.0.0.0`, `::`), the links name an address others reach.
|
||||
let at = reachable(host.addr()).to_string();
|
||||
let view = view_token.map(|v| Invite::new(at.clone(), file.clone(), v)).map(|v| WayIn { link: v.link(), url: v.url() });
|
||||
let invite = Invite::new(at.clone(), file, token);
|
||||
let info = SessionInfo {
|
||||
role: Role::Host,
|
||||
me: host.me().client_id,
|
||||
@@ -153,9 +194,10 @@ impl LiveLink for NetLink {
|
||||
pages,
|
||||
link: invite.link(),
|
||||
url: invite.url(),
|
||||
address: host.addr().to_string(),
|
||||
address: at,
|
||||
network: !ip.is_loopback(),
|
||||
access: Access::Edit,
|
||||
view,
|
||||
};
|
||||
self.started(session, Active::Host(host), inbox);
|
||||
Ok(info)
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::net::{SocketAddr, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use figcraft_collab::message::{self, CatchUp, ClientMsg, Hello, Presence, ServerMsg, Signal};
|
||||
use figcraft_collab::message::{self, Access, CatchUp, ClientMsg, Hello, Presence, ServerMsg, Signal};
|
||||
use figcraft_collab::{Client, Limits};
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use figcraft_engine::live::{Event, PEOPLE, Status, TOO_LARGE_TO_JOIN};
|
||||
@@ -78,6 +78,7 @@ pub(crate) fn fast_limits() -> NetLimits {
|
||||
NetLimits { hello_timeout: Duration::from_secs(5), ..NetLimits::default() }
|
||||
}
|
||||
|
||||
/// A host on a free loopback port with these limits, a probe of what it is told, and its invite.
|
||||
fn start_with(limits: NetLimits, core: Limits) -> (Host, Probe, Invite) {
|
||||
let probe = Probe::new();
|
||||
let mut doc = Document::new("Shared file");
|
||||
@@ -87,6 +88,7 @@ fn start_with(limits: NetLimits, core: Limits) -> (Host, Probe, Invite) {
|
||||
any_port_if_taken: false,
|
||||
file: FILE.into(),
|
||||
token: TOKEN.into(),
|
||||
view_token: None,
|
||||
doc,
|
||||
name: "Hana".into(),
|
||||
limits,
|
||||
@@ -214,6 +216,7 @@ fn guests_join_and_see_each_other() {
|
||||
assert_eq!(host.connections(), 0);
|
||||
}
|
||||
|
||||
/// A guest with the wrong token or for another file is refused, and the secret never shows.
|
||||
#[test]
|
||||
fn a_wrong_token_or_file_is_refused() {
|
||||
let (mut host, mut hp, invite) = start();
|
||||
@@ -236,6 +239,57 @@ fn a_wrong_token_or_file_is_refused() {
|
||||
host.stop();
|
||||
}
|
||||
|
||||
/// Join `addr` with `token` over a bare socket and make an edit: what the welcome allowed, and
|
||||
/// whether the host took the edit (`Changes`) or rejected it (`Reject`).
|
||||
fn join_and_edit(addr: SocketAddr, token: &str) -> (Access, ServerMsg) {
|
||||
let mut raw = Raw::hello(addr, token);
|
||||
let mut welcome = std::iter::from_fn(|| raw.next()).find(|m| matches!(m, ServerMsg::Welcome { .. })).expect("a welcome");
|
||||
message::unpack(&mut welcome, 64 << 20).unwrap();
|
||||
let ServerMsg::Welcome { access, .. } = welcome else { panic!("not a welcome: {welcome:?}") };
|
||||
let mut client = Client::join(welcome).unwrap();
|
||||
let mut doc = client.document().clone();
|
||||
doc.add_page("Mine");
|
||||
assert!(raw.send(&client.edit(&doc).unwrap().unwrap()));
|
||||
let answer = std::iter::from_fn(|| raw.next()).find(|m| matches!(m, ServerMsg::Changes(_) | ServerMsg::Reject { .. })).expect("an answer");
|
||||
(access, answer)
|
||||
}
|
||||
|
||||
/// A session hosted with a view token as well (a local network share's view-only code): whoever
|
||||
/// joins with it is welcomed as a viewer and their edits are rejected, the edit token still edits,
|
||||
/// any other token is still refused, and a view token equal to the edit token isn't hosted.
|
||||
#[test]
|
||||
fn a_view_token_lets_people_look_and_not_edit() {
|
||||
const VIEW: &str = "ViewOnlyViewOnlyView00";
|
||||
let probe = Probe::new();
|
||||
let cfg = |view: &str| {
|
||||
let mut doc = Document::new("Shared file");
|
||||
doc.repair();
|
||||
HostConfig {
|
||||
addr: SocketAddr::from(([127, 0, 0, 1], 0)),
|
||||
any_port_if_taken: false,
|
||||
file: FILE.into(),
|
||||
token: TOKEN.into(),
|
||||
view_token: Some(view.into()),
|
||||
doc,
|
||||
name: "Hana".into(),
|
||||
limits: fast_limits(),
|
||||
core: Limits::default(),
|
||||
}
|
||||
};
|
||||
let same = Host::start(cfg(TOKEN), probe.inbox.clone()).err().expect("a view token equal to the edit token is refused");
|
||||
assert!(same.to_string().contains("view-only"), "{same}");
|
||||
let mut host = Host::start(cfg(VIEW), probe.inbox.clone()).unwrap();
|
||||
let (access, answer) = join_and_edit(host.addr(), VIEW);
|
||||
assert_eq!(access, Access::View);
|
||||
assert!(matches!(&answer, ServerMsg::Reject { reason, .. } if reason.contains("view")), "{answer:?}");
|
||||
let (access, answer) = join_and_edit(host.addr(), TOKEN);
|
||||
assert_eq!(access, Access::Edit);
|
||||
assert!(matches!(answer, ServerMsg::Changes(_)), "{answer:?}");
|
||||
let refusal = Raw::hello(host.addr(), "WrongWrongWrongWrong00").refusal();
|
||||
assert!(refusal.as_deref().is_some_and(|r| r.contains("token")), "{refusal:?}");
|
||||
host.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreachable_hosts_fail_at_once() {
|
||||
// A port nothing listens on.
|
||||
@@ -677,6 +731,7 @@ fn the_host_removes_someone_and_they_cant_come_back() {
|
||||
assert_eq!(host.connections(), 0);
|
||||
}
|
||||
|
||||
/// A welcome larger than the guest takes says the file is too large to join.
|
||||
#[test]
|
||||
fn a_welcome_over_the_size_limit_says_the_file_is_too_large() {
|
||||
let mut doc = Document::new("B".repeat(1 << 20));
|
||||
@@ -687,6 +742,7 @@ fn a_welcome_over_the_size_limit_says_the_file_is_too_large() {
|
||||
any_port_if_taken: false,
|
||||
file: FILE.into(),
|
||||
token: TOKEN.into(),
|
||||
view_token: None,
|
||||
doc,
|
||||
name: "Hana".into(),
|
||||
limits: fast_limits(),
|
||||
|
||||
@@ -81,11 +81,12 @@ fn only_a_comment_link_leaves_the_comment_tool() {
|
||||
assert!(!tool_left(crate::live::PUBLISHING, ToolId::Comment));
|
||||
}
|
||||
|
||||
/// A local network share has edit and view-only codes, not a comment code, and says where one is.
|
||||
#[test]
|
||||
fn a_local_share_has_no_comment_code() {
|
||||
let (mut s, _) = session();
|
||||
let e = s.execute("collab.share", &json!({"via": "local", "access": "comment"})).unwrap_err().to_string();
|
||||
assert!(e.contains("can edit it"), "{e}");
|
||||
assert!(e.contains("share it online for a comment code"), "{e}");
|
||||
let e = s.execute("collab.share", &json!({"access": "owner"})).unwrap_err().to_string();
|
||||
assert!(e.contains("\"comment\""), "{e}");
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use serde_json::{Value, json};
|
||||
|
||||
use crate::live::{
|
||||
Access, Bind, DEFAULT_SERVER, Event, HostOptions, LiveLink, Presence, PublishOptions, Published, Role, SessionInfo, Shared, SharedLink, Status,
|
||||
join_target,
|
||||
WayIn, join_target,
|
||||
};
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
@@ -36,13 +36,23 @@ struct Link(Arc<Mutex<Script>>);
|
||||
|
||||
/// Where the scripted host listens, and its session's secret.
|
||||
const HOST: Local = Local { ip: Ipv4Addr::new(192, 168, 1, 20), port: 47_900, secret: 0x123_4567_89AB };
|
||||
/// The same session's view-only secret.
|
||||
const VIEW: Local = Local { secret: 0xBA9_8765_4321, ..HOST };
|
||||
|
||||
/// The WebSocket URL the scripted link gives with an invite `link` (the same secret).
|
||||
fn mock_url(link: &str) -> String {
|
||||
link.replace("figcraft://join?host=", "ws://")
|
||||
}
|
||||
|
||||
impl LiveLink for Link {
|
||||
/// Host as the network link does: a code's short secret when shared with a code, and the
|
||||
/// view-only link when asked for one.
|
||||
fn host(&mut self, session: u64, opts: HostOptions) -> Result<SessionInfo, String> {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
s.session = session;
|
||||
// As the network link does: a short secret when the session is shared with a code.
|
||||
let link = if opts.short { HOST.link() } else { "figcraft://join?host=192.168.1.20:47900&file=f&token=AbCdEfGhIjKlMnOpQrStUv".to_string() };
|
||||
let view = opts.view.then(|| WayIn { link: VIEW.link(), url: mock_url(&VIEW.link()) });
|
||||
s.hosted.push(opts);
|
||||
Ok(SessionInfo {
|
||||
role: Role::Host,
|
||||
@@ -51,11 +61,12 @@ impl LiveLink for Link {
|
||||
color: "#0D99FF".into(),
|
||||
file_name: "Shared".into(),
|
||||
pages: vec![],
|
||||
url: link.replace("figcraft://join?host=", "ws://"),
|
||||
url: mock_url(&link),
|
||||
link,
|
||||
address: "192.168.1.20:47900".into(),
|
||||
network: true,
|
||||
access: Access::Edit,
|
||||
view,
|
||||
})
|
||||
}
|
||||
fn join(&mut self, session: u64, link: &str, _: &str) -> Result<(), String> {
|
||||
@@ -146,6 +157,7 @@ fn on_the_server(s: &mut Session, script: &Arc<Mutex<Script>>, codes: Option<(&s
|
||||
address: "wss://share.example:443/".into(),
|
||||
network: true,
|
||||
access: Access::Edit,
|
||||
view: None,
|
||||
};
|
||||
let events = vec![Event::Published(published), Event::Shared(Ok(links(0, codes))), Event::Welcome { info, peers: vec![] }];
|
||||
script.lock().unwrap().events.extend(events);
|
||||
@@ -352,6 +364,8 @@ fn online_sharing_is_off_without_a_server() {
|
||||
assert!(matches!(bare.execute("collab.join", &json!({"code": HOST.code()})), Err(EngineError::Disabled(..))));
|
||||
}
|
||||
|
||||
/// A local network share shows the code that carries the host's address, has a view-only code
|
||||
/// for the same session, switches which shows without hosting again, and listens where told.
|
||||
#[test]
|
||||
fn sharing_on_the_local_network_shows_a_code_that_carries_the_address() {
|
||||
let (mut s, script) = session();
|
||||
@@ -360,30 +374,48 @@ fn sharing_on_the_local_network_shows_a_code_that_carries_the_address() {
|
||||
let shown = r["code"].as_str().unwrap();
|
||||
assert_eq!(code::parse(shown), Ok(code::Code::Local(HOST)), "{shown}");
|
||||
assert_eq!(shown, code::grouped(&HOST.code()));
|
||||
assert_eq!((&r["link"], &r["share"]["viewCode"]), (&json!(HOST.link()), &Value::Null));
|
||||
assert_eq!(r["link"], json!(HOST.link()));
|
||||
// A view-only code too, for the same session with its own secret; no comment code.
|
||||
let view = code::grouped(&VIEW.code());
|
||||
assert_eq!((&r["share"]["viewCode"], &r["share"]["viewLink"]), (&json!(view), &json!(VIEW.link())));
|
||||
assert_eq!((&r["share"]["editLink"], &r["share"]["commentCode"]), (&json!(HOST.link()), &Value::Null));
|
||||
{
|
||||
let sc = script.lock().unwrap();
|
||||
let [h] = sc.hosted.as_slice() else { panic!("hosted {}", sc.hosted.len()) };
|
||||
assert!(h.short && h.bind == Bind::Lan && h.port.is_none(), "the local network unless told otherwise");
|
||||
assert!(h.short && h.view && h.bind == Bind::Lan && h.port.is_none(), "the local network unless told otherwise");
|
||||
}
|
||||
// Everyone edits there.
|
||||
assert!(err(&mut s, "collab.share", json!({"access": "view"})).contains("share it online"));
|
||||
// Which code shows: the view code, then the edit code again (nothing is hosted twice).
|
||||
// The link goes with the code: a view-only code's link doesn't edit.
|
||||
let r = ex(&mut s, "collab.share", json!({"access": "view"}));
|
||||
assert_eq!((&r["code"], &r["share"]["access"]), (&json!(view), &json!("view")));
|
||||
assert_eq!((&r["link"], &r["url"], &r["sessions"][0]["link"]), (&json!(VIEW.link()), &json!(mock_url(&VIEW.link())), &json!(VIEW.link())));
|
||||
let r = ex(&mut s, "collab.share", json!({"access": "edit"}));
|
||||
assert_eq!((&r["code"], &r["link"], &r["url"]), (&json!(code::grouped(&HOST.code())), &json!(HOST.link()), &json!(mock_url(&HOST.link()))));
|
||||
assert!(err(&mut s, "collab.share", json!({"access": "comment"})).contains("share it online"));
|
||||
assert_eq!(script.lock().unwrap().hosted.len(), 1);
|
||||
assert_eq!(ex(&mut s, "collab.stop", json!({}))["ended"], true);
|
||||
assert!(script.lock().unwrap().asked.is_empty(), "no server to tell");
|
||||
assert!(err(&mut s, "collab.share", json!({"via": "local", "access": "view"})).contains("share it online"));
|
||||
// Shared for looking only from the start: the edit code isn't the one shown.
|
||||
let r = ex(&mut s, "collab.share", json!({"via": "local", "access": "view"}));
|
||||
assert_eq!((&r["code"], &r["share"]["access"]), (&json!(view), &json!("view")));
|
||||
assert_eq!((&r["link"], &r["url"]), (&json!(VIEW.link()), &json!(mock_url(&VIEW.link()))), "the edit link and URL aren't handed out");
|
||||
assert_eq!(r["sessions"][0]["link"], json!(VIEW.link()));
|
||||
ex(&mut s, "collab.stop", json!({}));
|
||||
assert!(err(&mut s, "collab.share", json!({"via": "local", "access": "comment"})).contains("share it online"));
|
||||
// Where to listen is the advanced part.
|
||||
ex(&mut s, "collab.share", json!({"via": "local", "bind": "local", "port": 5000}));
|
||||
let sc = script.lock().unwrap();
|
||||
assert!(sc.hosted[1].bind == Bind::Local && sc.hosted[1].port == Some(5000));
|
||||
assert!(sc.hosted[2].bind == Bind::Local && sc.hosted[2].port == Some(5000));
|
||||
drop(sc);
|
||||
ex(&mut s, "collab.stop", json!({}));
|
||||
for bad in [json!({"via": "post"}), json!({"via": "local", "port": 70000}), json!({"via": "local", "bind": "everywhere"})] {
|
||||
assert!(matches!(s.execute("collab.share", &bad), Err(EngineError::BadParams { .. })), "{bad}");
|
||||
}
|
||||
// A plain `collab.host` session has an invite link, not a code.
|
||||
// A plain `collab.host` session has an invite link, not a code, and everyone in it edits.
|
||||
let r = ex(&mut s, "collab.host", json!({}));
|
||||
assert_eq!((&r["code"], &r["share"]), (&Value::Null, &Value::Null));
|
||||
assert!(!script.lock().unwrap().hosted[2].short);
|
||||
let h = &script.lock().unwrap().hosted[3];
|
||||
assert!(!h.short && !h.view);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -90,6 +90,7 @@ impl LiveLink for Mock {
|
||||
}
|
||||
}
|
||||
|
||||
/// A session you are in as `role`, as client `me`, everyone in it editing.
|
||||
pub(crate) fn info(role: Role, me: u32) -> SessionInfo {
|
||||
SessionInfo {
|
||||
role,
|
||||
@@ -103,6 +104,7 @@ pub(crate) fn info(role: Role, me: u32) -> SessionInfo {
|
||||
address: "127.0.0.1:47900".into(),
|
||||
network: false,
|
||||
access: crate::live::Access::Edit,
|
||||
view: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -136,6 +136,7 @@ fn edit_link(server: &str, file: &str) -> String {
|
||||
format!("figcraft://join?server={server}&file={file}&token={EDIT_TOKEN}")
|
||||
}
|
||||
|
||||
/// A session you are in as `role` under `name`, invited with `link`.
|
||||
fn info(role: Role, name: &str, link: &str) -> SessionInfo {
|
||||
SessionInfo {
|
||||
role,
|
||||
@@ -149,6 +150,7 @@ fn info(role: Role, name: &str, link: &str) -> SessionInfo {
|
||||
address: SERVER.into(),
|
||||
network: true,
|
||||
access: Access::Edit,
|
||||
view: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ pub(crate) use join::start_join;
|
||||
pub use join::{can_remove_people, specs};
|
||||
pub use types::{
|
||||
Bind, DEFAULT_SERVER, Event, HostOptions, LiveLink, MAX_NAME, MAX_PEERS, MAX_SELECTION, NO_HOSTING, NO_SESSION, NetReply, NetRequest, ONLINE_OFF,
|
||||
PEOPLE, PageInfo, PublishOptions, Published, Role, SessionId, SessionInfo, Share, Shared, SharedLink, Status, TOO_LARGE_TO_JOIN, Via,
|
||||
PEOPLE, PageInfo, PublishOptions, Published, Role, SessionId, SessionInfo, Share, Shared, SharedLink, Status, TOO_LARGE_TO_JOIN, Via, WayIn,
|
||||
join_target, parse_color,
|
||||
};
|
||||
|
||||
@@ -147,6 +147,18 @@ impl Live {
|
||||
self.info.as_ref().map(|i| i.role)
|
||||
}
|
||||
|
||||
/// The session's invite link and WebSocket URL as `collab.peers` gives them. A file shared
|
||||
/// on the local network gives the ones that go with the code shown: the session's own link
|
||||
/// and URL are the edit way in, and a view-only share must not hand them out. Otherwise the
|
||||
/// session's own (online, the owner's: `share` has the links for others).
|
||||
pub fn invite(&self) -> (Option<&str>, Option<&str>) {
|
||||
let info = self.info.as_ref();
|
||||
match &self.share {
|
||||
Some(sh) if sh.via == Via::Local => info.and_then(|i| i.way_in(sh.access)).unzip(),
|
||||
_ => (info.map(|i| i.link.as_str()), info.map(|i| i.url.as_str())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop following. False when you weren't following anyone.
|
||||
fn unfollow(&mut self) -> bool {
|
||||
self.followed = None;
|
||||
|
||||
@@ -36,14 +36,16 @@ pub(super) fn unavailable(cmd: &str) -> EngineError {
|
||||
)
|
||||
}
|
||||
|
||||
/// `collab.host`: host the open file (on this machine unless `p` says where) with an invite link.
|
||||
pub(super) fn host(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
start_host(s, "collab.host", p, Bind::Local, false)?;
|
||||
start_host(s, "collab.host", p, Bind::Local, false, false)?;
|
||||
Ok(s.live_summary())
|
||||
}
|
||||
|
||||
/// Start hosting the open file for command `cmd` (on `bind` unless `p` says where; `short`: with
|
||||
/// a secret a join code can carry): the new session.
|
||||
fn start_host(s: &mut Session, cmd: &str, p: &Value, bind: Bind, short: bool) -> Result<SessionId> {
|
||||
/// a secret a join code can carry; `view`: with a second secret for looking only): the new
|
||||
/// session.
|
||||
fn start_host(s: &mut Session, cmd: &str, p: &Value, bind: Bind, short: bool, view: bool) -> Result<SessionId> {
|
||||
let port = match get(p, "port") {
|
||||
None => None,
|
||||
Some(v) => Some(v.as_u64().and_then(|n| u16::try_from(n).ok()).ok_or_else(|| bad(cmd, "`port` must be a whole number from 0 to 65535"))?),
|
||||
@@ -55,7 +57,7 @@ fn start_host(s: &mut Session, cmd: &str, p: &Value, bind: Bind, short: bool) ->
|
||||
Some(other) => other.parse::<IpAddr>().map(Bind::Addr).map_err(|_| bad(cmd, "`bind` must be \"local\", \"lan\" or an IP address"))?,
|
||||
};
|
||||
let (doc, uid) = s.ds().map(|ds| (ds.doc.clone(), ds.uid))?;
|
||||
let opts = HostOptions { port, bind, name: display_name(&s.prefs), doc, short };
|
||||
let opts = HostOptions { port, bind, name: display_name(&s.prefs), doc, short, view };
|
||||
let (id, info) = s.live.start(cmd, |l, id| l.host(id, opts).map_err(|e| EngineError::Other(format!("couldn't start the live session: {e}"))))?;
|
||||
s.live.open(Live { id, status: Status::Live, info: Some(info), doc_uid: Some(uid), tab: Some(uid), ..Live::default() });
|
||||
Ok(id)
|
||||
@@ -82,8 +84,9 @@ fn start_publish(s: &mut Session, cmd: &str, server: &str, p: &Value, share: boo
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Why a local network share has no view-only or comment code.
|
||||
const LOCAL_EDITS: &str = "everyone who joins a file shared on the local network can edit it: share it online for a view-only or comment code";
|
||||
/// Why a local network share has no comment code.
|
||||
const LOCAL_COMMENTS: &str =
|
||||
"a file shared on the local network has an edit code and a view-only code, not a comment code: share it online for a comment code";
|
||||
|
||||
/// Why a share has no comment code.
|
||||
const NO_COMMENT_LINK: &str =
|
||||
@@ -94,6 +97,8 @@ fn access_param(cmd: &str, p: &Value) -> Result<Option<Access>> {
|
||||
string(p, "access").map(|a| Access::parse(&a).ok_or_else(|| bad(cmd, "`access` must be \"edit\", \"comment\" or \"view\""))).transpose()
|
||||
}
|
||||
|
||||
/// `collab.share`: share the open file with join codes, online or on the local network, or (shared
|
||||
/// already) switch which code shows.
|
||||
pub(super) fn share(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "collab.share";
|
||||
let via = match string(p, "via").as_deref().map(str::trim) {
|
||||
@@ -112,8 +117,8 @@ pub(super) fn share(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"this file is shared the other way already: stop sharing it first (collab.stop), or leave its session (collab.leave)",
|
||||
));
|
||||
}
|
||||
if access.is_some_and(|a| a != Access::Edit) && shared == Via::Local {
|
||||
return Err(bad(CMD, LOCAL_EDITS));
|
||||
if access == Some(Access::Comment) && shared == Via::Local {
|
||||
return Err(bad(CMD, LOCAL_COMMENTS));
|
||||
}
|
||||
if let Some(req) = share_request(s, Vec::new(), true).filter(|_| s.live().share.as_ref().is_some_and(|sh| sh.links.is_none())) {
|
||||
let links = share_answer(s, req)?.ok_or_else(|| EngineError::Other("the server made no links".into()))?;
|
||||
@@ -130,8 +135,8 @@ pub(super) fn share(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let access = access.unwrap_or_default();
|
||||
let mut server = String::new();
|
||||
let id = match via {
|
||||
Via::Local if access != Access::Edit => return Err(bad(CMD, LOCAL_EDITS)),
|
||||
Via::Local => start_host(s, CMD, p, Bind::Lan, true)?,
|
||||
Via::Local if access == Access::Comment => return Err(bad(CMD, LOCAL_COMMENTS)),
|
||||
Via::Local => start_host(s, CMD, p, Bind::Lan, true, true)?,
|
||||
Via::Online => {
|
||||
server = string(p, "server").unwrap_or_else(|| s.prefs.publish_server.clone()).trim().to_string();
|
||||
if server.is_empty() {
|
||||
@@ -141,17 +146,25 @@ pub(super) fn share(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
};
|
||||
if let Some(live) = s.live.sessions.get_mut(&id) {
|
||||
// On the local network the session's own link is what others join with, and the code says
|
||||
// what the link says: where the session is and its secret. (Online they arrive with the file.)
|
||||
let links = live.info.as_ref().filter(|_| via == Via::Local).map(|i| {
|
||||
let code = code::Local::from_link(&i.link).map(|l| l.code());
|
||||
Shared { edit: SharedLink { id: String::new(), link: i.link.clone(), code }, ..Shared::default() }
|
||||
// On the local network the session's own links are what others join with (its view link
|
||||
// only lets them look), and each code says what its link says: where the session is and
|
||||
// that link's secret. (Online they arrive with the file.)
|
||||
let links = live.info.as_ref().filter(|_| via == Via::Local).map(|i| Shared {
|
||||
edit: local_link(&i.link),
|
||||
view: i.view.as_ref().map(|v| local_link(&v.link)),
|
||||
comment: None,
|
||||
});
|
||||
live.share = Some(Share { via, server, access, links });
|
||||
}
|
||||
Ok(s.live_summary())
|
||||
}
|
||||
|
||||
/// A way into a session this app hosts: its invite link and the join code that stands for it
|
||||
/// (none when the link can't be put in one: a host without an IPv4 address).
|
||||
fn local_link(link: &str) -> SharedLink {
|
||||
SharedLink { id: String::new(), link: link.to_string(), code: code::Local::from_link(link).map(|l| l.code()) }
|
||||
}
|
||||
|
||||
/// What others join the open file's server file with changed: in its session and its record.
|
||||
pub(super) fn shared_now(s: &mut Session, links: Option<Shared>) {
|
||||
if let Some((server, file)) = s.live().server_file() {
|
||||
|
||||
@@ -162,6 +162,7 @@ fn spotlight(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"spotlighting": s.live().spotlighting}))
|
||||
}
|
||||
|
||||
/// The live session commands (`collab.*`).
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!(
|
||||
@@ -174,7 +175,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"collab.share",
|
||||
"Share with a join code",
|
||||
"{via?: \"online\" (default: the open file is kept on the server in Preferences, `pref.publishServer`, and the server hands out the codes) | \"local\" (this app hosts it for people on the same network; no server), access?: \"edit\" (default) | \"comment\" (online only: view and comment, not edit) | \"view\" (online only: which code `code` is), server? (online: another server's address, with ?key=… when it needs its key), key? (online: the server's key, when the address has none), name? (online: the file's name there), port?, bind?: \"lan\" (default) | \"local\" (this machine only) | an IP address (local)} → the open file's session as `collab.peers` gives it: `code` is what others type to join (`collab.join {code}`, the Join field), `link` an invite link. Local: at once, the code carries the host's IPv4 address, port and secret (K7M2Q-…, 20 symbols). Online: status \"connecting\" first; once live `code` is the server's (K7M-2QX-9TD) and `share` is {via, access, shared, editCode, viewCode, commentCode, editLink, viewLink, commentLink}: links made for others (commentCode and commentLink null from a server older than comment links) (the file's own links in `published` stay yours); `code` stays null when the server is older than join codes (give `share.editLink` / `share.viewLink` instead). Called again while shared: only switches which code `code` is (`access`). Called after `collab.stop` on a file that is still on the server: new links and codes for that same file (nothing is published twice). `collab.stop` stops sharing",
|
||||
"{via?: \"online\" (default: the open file is kept on the server in Preferences, `pref.publishServer`, and the server hands out the codes) | \"local\" (this app hosts it for people on the same network; no server), access?: \"edit\" (default) | \"comment\" (online only: view and comment, not edit) | \"view\" (look only: their edits are refused): which code `code` is, server? (online: another server's address, with ?key=… when it needs its key), key? (online: the server's key, when the address has none), name? (online: the file's name there), port?, bind?: \"lan\" (default) | \"local\" (this machine only) | an IP address (local)} → the open file's session as `collab.peers` gives it: `code` is what others type to join (`collab.join {code}`, the Join field), `link` the invite link to give with it and `url` its WebSocket URL (local: the ones that go with `code`, so a view-only code's link and URL let people look only; online: your own link to the file, which edits: `share` has the link that goes with each code). Local: at once, the code carries the host's IPv4 address, port and secret (K7M2Q-…, 20 symbols), and `share` is {via, access, shared, editCode, viewCode, editLink, viewLink}: the edit and view-only ways in, each with its own secret (commentCode and commentLink null). Online: status \"connecting\" first; once live `code` is the server's (K7M-2QX-9TD) and `share` is {via, access, shared, editCode, viewCode, commentCode, editLink, viewLink, commentLink}: links made for others (commentCode and commentLink null from a server older than comment links) (the file's own links in `published` stay yours); `code` stays null when the server is older than join codes (give `share.editLink` / `share.viewLink` instead). Called again while shared: only switches which code `code` is (`access`). Called after `collab.stop` on a file that is still on the server: new links and codes for that same file (nothing is published twice). `collab.stop` stops sharing",
|
||||
false,
|
||||
share
|
||||
),
|
||||
@@ -230,7 +231,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"collab.peers",
|
||||
"People in this file",
|
||||
"{} → the open file's session (each open file has its own or none): {available, canHost (false in the browser: it can only join), session (its id; null: this file isn't in a session), status: off|connecting|live|reconnecting|failed, reason?, role, me, name, color, fileName, pages, link, url, address, network, access: edit|view|comment (view: a server's view link, edits are refused; comment: its comment link, edits other than to comment threads are refused), readOnly (the open file can't be edited now: view only, comment only, or being published), published (a file you published: {server, file, name, edit, view}), code (the join code to give others, when the file was shared with `collab.share`; null otherwise or while it is on its way), share ({via: online|local, access, shared (false: a file of yours on a server that nobody else gets into now), editCode, viewCode, commentCode, editLink, viewLink, commentLink (what others join with; null when not shared)} or null), follow, syncing, document (index of the synced open document), editing (the text layer you type in: {node, layer, caret, selection}), peers: [{id, name, color, editing (theirs, same shape, or null), presence: {cursor: [page, x, y], selection, viewport: [page, x, y, w, h], editing_text}}], sessions: [{session, document (index of its open document; null for a join on its way), status, reason, role, fileName, link, peers (how many others)}] (every session of the app)}",
|
||||
"{} → the open file's session (each open file has its own or none): {available, canHost (false in the browser: it can only join), session (its id; null: this file isn't in a session), status: off|connecting|live|reconnecting|failed, reason?, role, me, name, color, fileName, pages, link, url, address, network, access: edit|view|comment (view: a view link or code, a server's or a local network share's, edits are refused; comment: a server's comment link, edits other than to comment threads are refused), readOnly (the open file can't be edited now: view only, comment only, or being published), published (a file you published: {server, file, name, edit, view}), code (the join code to give others, when the file was shared with `collab.share`; null otherwise or while it is on its way), share ({via: online|local, access, shared (false: a file of yours on a server that nobody else gets into now), editCode, viewCode, commentCode, editLink, viewLink, commentLink (what others join with; null when not shared)} or null), follow, syncing, document (index of the synced open document), editing (the text layer you type in: {node, layer, caret, selection}), peers: [{id, name, color, editing (theirs, same shape, or null), presence: {cursor: [page, x, y], selection, viewport: [page, x, y, w, h], editing_text}}], sessions: [{session, document (index of its open document; null for a join on its way), status, reason, role, fileName, link, peers (how many others)}] (every session of the app)}",
|
||||
false,
|
||||
|s, _| Ok(s.live_summary())
|
||||
),
|
||||
|
||||
@@ -283,6 +283,7 @@ impl Session {
|
||||
})
|
||||
.collect();
|
||||
let mine = synced.and_then(|d| d.text_edit.as_ref()).map(|t| editing(&editing_of(t)));
|
||||
let (link, url) = l.invite();
|
||||
let sessions: Vec<Value> = self
|
||||
.live
|
||||
.iter()
|
||||
@@ -295,7 +296,7 @@ impl Session {
|
||||
"reason": reason(o),
|
||||
"role": o.role(),
|
||||
"fileName": info.map(|i| i.file_name.as_str()),
|
||||
"link": info.map(|i| i.link.as_str()),
|
||||
"link": o.invite().0,
|
||||
"peers": o.peers.len(),
|
||||
})
|
||||
})
|
||||
@@ -313,8 +314,8 @@ impl Session {
|
||||
"color": info.map(|i| i.color.as_str()),
|
||||
"fileName": info.map(|i| i.file_name.as_str()),
|
||||
"pages": info.map(|i| &i.pages),
|
||||
"link": info.map(|i| i.link.as_str()),
|
||||
"url": info.map(|i| i.url.as_str()),
|
||||
"link": link,
|
||||
"url": url,
|
||||
"address": info.map(|i| i.address.as_str()),
|
||||
"network": info.map(|i| i.network),
|
||||
"access": l.client.as_ref().map(Client::access).or(info.map(|i| i.access)),
|
||||
|
||||
@@ -119,9 +119,33 @@ pub struct SessionInfo {
|
||||
pub address: String,
|
||||
/// Reachable from other machines (the host listens on a network address, not loopback).
|
||||
pub network: bool,
|
||||
/// What you may do: edit, only view (a server's view link), or view and comment (its comment
|
||||
/// link).
|
||||
/// What you may do: edit, only view (a view link or code), or view and comment (a server's
|
||||
/// comment link).
|
||||
pub access: Access,
|
||||
/// A session this app hosts with a way in for looking only ([`HostOptions::view`]): its
|
||||
/// invite link and WebSocket URL, which carry the view secret (`None`: everyone who joins
|
||||
/// edits, or you joined).
|
||||
pub view: Option<WayIn>,
|
||||
}
|
||||
|
||||
impl SessionInfo {
|
||||
/// The invite link and WebSocket URL that let people into a session this app hosts with
|
||||
/// `access` (its own link and URL edit). `None`: no way in gives that.
|
||||
pub fn way_in(&self, access: Access) -> Option<(&str, &str)> {
|
||||
match access {
|
||||
Access::Edit => Some((&self.link, &self.url)),
|
||||
Access::View => self.view.as_ref().map(|v| (v.link.as_str(), v.url.as_str())),
|
||||
Access::Comment => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A way into a session this app hosts: an invite link and the WebSocket URL it connects to,
|
||||
/// which carry the same secret.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct WayIn {
|
||||
pub link: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// A file you published to a server: where, and its first share links (they carry their tokens:
|
||||
@@ -390,6 +414,9 @@ pub struct HostOptions {
|
||||
pub doc: Arc<Document>,
|
||||
/// Shared with a join code (`collab.share`): the session's secret is short enough to type.
|
||||
pub short: bool,
|
||||
/// Also make a second secret whose holders only look: they see every change, and the session
|
||||
/// refuses theirs ([`SessionInfo::view`]).
|
||||
pub view: bool,
|
||||
}
|
||||
|
||||
/// What [`LiveLink::publish`] needs to put the open file on a server.
|
||||
|
||||
@@ -127,6 +127,7 @@ name = "English"
|
||||
"Another server, or one that needs its key. The usual server is set in Preferences." = "Another server, or one that needs its key. The usual server is set in Preferences."
|
||||
"Any status" = "Any status"
|
||||
"Anyone on your network with this code can edit the file, while this app stays open." = "Anyone on your network with this code can edit the file, while this app stays open."
|
||||
"Anyone on your network with this code can look at the file, not change it, while this app stays open." = "Anyone on your network with this code can look at the file, not change it, while this app stays open."
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them."
|
||||
"Anyone with this code can edit the file." = "Anyone with this code can edit the file."
|
||||
"Anyone with this code can look at the file, not change it." = "Anyone with this code can look at the file, not change it."
|
||||
|
||||
@@ -108,6 +108,9 @@ pub struct LiveUi {
|
||||
focus_join: bool,
|
||||
/// Share on the local network (else online, through the server in Preferences).
|
||||
pub local: bool,
|
||||
/// What the code a local network share starts with lets people do: edit, or only look (the
|
||||
/// edit code then doesn't show until it is picked).
|
||||
pub local_access: Access,
|
||||
/// The Share popover's Advanced part shows; what it sets for a local network share: only
|
||||
/// this computer can join, and the port (empty: the usual one).
|
||||
pub advanced: bool,
|
||||
@@ -852,6 +855,7 @@ fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
}
|
||||
if local {
|
||||
widgets::label(ui, tr("People on your network join with a code. Nothing goes through a server."), t.text_secondary);
|
||||
local_access_choice(app, ui);
|
||||
} else {
|
||||
online_note(app, ui, t, &server);
|
||||
}
|
||||
@@ -861,10 +865,24 @@ fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
join_and_advanced(app, ui, t, |app, ui, t| share_advanced(app, ui, t, local, &server));
|
||||
}
|
||||
|
||||
/// What the code of a local network share lets people do, picked before it starts: edit the file
|
||||
/// or only look at it.
|
||||
fn local_access_choice(app: &mut FigApp, ui: &mut Ui) {
|
||||
let levels: Vec<(Access, &str)> = LEVELS.into_iter().filter(|(a, _)| *a != Access::Comment).collect();
|
||||
let labels: Vec<&str> = levels.iter().map(|(_, l)| tr(l)).collect();
|
||||
let at = levels.iter().position(|(a, _)| *a == app.ui.live.local_access).unwrap_or_default();
|
||||
if let Some((a, _)) = widgets::segmented_text(ui, &labels, at).and_then(|i| levels.get(i)) {
|
||||
app.ui.live.local_access = *a;
|
||||
}
|
||||
}
|
||||
|
||||
/// Start sharing the open file on the local network, or online on `server` (the one in
|
||||
/// Preferences).
|
||||
fn start_sharing(app: &mut FigApp, local: bool, server: &str) {
|
||||
let mut params = json!({"via": if local { "local" } else { "online" }});
|
||||
if local {
|
||||
params["access"] = json!(app.ui.live.local_access.id());
|
||||
}
|
||||
if local && app.ui.live.this_computer {
|
||||
params["bind"] = json!("local");
|
||||
}
|
||||
@@ -1068,6 +1086,7 @@ fn share_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens, share: &Share) {
|
||||
(true, Access::View) => tr("Anyone with this code can look at the file, not change it."),
|
||||
(true, Access::Comment) => tr("Anyone with this code can look at the file and comment on it, not change it."),
|
||||
(true, Access::Edit) => tr("Anyone with this code can edit the file."),
|
||||
(false, Access::View) => tr("Anyone on your network with this code can look at the file, not change it, while this app stays open."),
|
||||
(false, _) => tr("Anyone on your network with this code can edit the file, while this app stays open."),
|
||||
};
|
||||
widgets::label(ui, who, t.text_secondary);
|
||||
@@ -1081,7 +1100,8 @@ fn share_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens, share: &Share) {
|
||||
}
|
||||
|
||||
/// What the code shown lets people do, to pick from the levels the share has links for: a server
|
||||
/// has links (and codes) for looking only and for commenting; a session this app hosts doesn't.
|
||||
/// has links (and codes) for looking only and for commenting; a session this app hosts on the
|
||||
/// local network has one for looking only.
|
||||
fn access_choice(app: &mut FigApp, ui: &mut Ui, share: &Share) {
|
||||
let levels: Vec<(Access, &str)> = LEVELS.into_iter().filter(|(a, _)| share.links.as_ref().is_some_and(|l| l.of(*a).is_some())).collect();
|
||||
if levels.len() < 2 {
|
||||
|
||||
@@ -17,6 +17,7 @@ type Queue = Arc<Mutex<Vec<(u64, LiveEvent)>>>;
|
||||
struct Hosting(Queue);
|
||||
|
||||
impl LiveLink for Hosting {
|
||||
/// Host at once, with an invite link and nobody else in the session.
|
||||
fn host(&mut self, session: u64, _: HostOptions) -> Result<SessionInfo, String> {
|
||||
let address = format!("127.0.0.1:{}", 47_899 + session);
|
||||
Ok(SessionInfo {
|
||||
@@ -31,6 +32,7 @@ impl LiveLink for Hosting {
|
||||
address,
|
||||
network: false,
|
||||
access: Access::Edit,
|
||||
view: None,
|
||||
})
|
||||
}
|
||||
fn join(&mut self, _: u64, _: &str, _: &str) -> Result<(), String> {
|
||||
|
||||
@@ -44,6 +44,7 @@ pub(crate) fn joined(h: &mut Harness, access: Access) {
|
||||
address: "127.0.0.1:47900".into(),
|
||||
network: false,
|
||||
access,
|
||||
view: None,
|
||||
};
|
||||
h.send(vec![LiveEvent::Welcome { info, peers: vec![] }, LiveEvent::Sync(w)]);
|
||||
h.frames(2);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Sharing with a join code in the window: the Share popover not sharing (online or the local
|
||||
//! network, one Start button, the rest under Advanced), sharing online (the code large, copying,
|
||||
//! what it lets people do, who is here, stop: the file stays on the server, not shared, and can
|
||||
//! be shared again or removed), sharing on the local network, a server without
|
||||
//! be shared again or removed), sharing on the local network (to edit, or to look only), a server without
|
||||
//! codes, joining with the one join field (the popover, the home screen, the Join dialog), its
|
||||
//! errors, the server preference, and an idle popover asking for no frames.
|
||||
|
||||
@@ -13,7 +13,7 @@ use figcraft_engine::figcraft_collab::code::{self, Local};
|
||||
use figcraft_engine::figcraft_collab::message::ClientMsg;
|
||||
use figcraft_engine::live::{
|
||||
Access, DEFAULT_SERVER, Event as LiveEvent, HostOptions, LiveLink, NetReply, NetRequest, Peer, Presence, PublishOptions, Published, Role,
|
||||
SessionInfo, Shared as SharedLinks, SharedLink, Status,
|
||||
SessionInfo, Shared as SharedLinks, SharedLink, Status, WayIn,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -53,9 +53,12 @@ struct Link(Shared);
|
||||
|
||||
/// Where the scripted host listens, and its session's secret.
|
||||
const HOST: Local = Local { ip: std::net::Ipv4Addr::new(192, 168, 1, 20), port: 47_900, secret: 0x123_4567_89AB };
|
||||
/// The same session's view-only secret.
|
||||
const VIEW: Local = Local { secret: 0xBA9_8765_4321, ..HOST };
|
||||
/// A link without a code in it (an address a code can't carry).
|
||||
const PLAIN: &str = "figcraft://join?host=[fd00::1]:47900&file=f&token=AbCdEfGhIjKlMnOpQrStUv";
|
||||
|
||||
/// What the scripted link says about a session you are in as `role`, invited with `link`.
|
||||
fn info(role: Role, link: &str) -> SessionInfo {
|
||||
SessionInfo {
|
||||
role,
|
||||
@@ -69,16 +72,20 @@ fn info(role: Role, link: &str) -> SessionInfo {
|
||||
address: "192.168.1.20:47900".into(),
|
||||
network: true,
|
||||
access: Access::Edit,
|
||||
view: None,
|
||||
}
|
||||
}
|
||||
|
||||
impl LiveLink for Link {
|
||||
/// Host as the network link does: port 6 stands for an address a code can't carry, and a
|
||||
/// share for looking only gets a view link too.
|
||||
fn host(&mut self, session: u64, opts: HostOptions) -> Result<SessionInfo, String> {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
s.session = session;
|
||||
let link = if opts.port == Some(6) { PLAIN.to_string() } else { HOST.link() };
|
||||
let (link, view) = if opts.port == Some(6) { (PLAIN.to_string(), PLAIN.replace("AbCd", "View")) } else { (HOST.link(), VIEW.link()) };
|
||||
let view = opts.view.then(|| WayIn { url: view.replace("figcraft://join?host=", "ws://"), link: view });
|
||||
s.hosted.push(opts);
|
||||
Ok(info(Role::Host, &link))
|
||||
Ok(SessionInfo { view, ..info(Role::Host, &link) })
|
||||
}
|
||||
fn join(&mut self, session: u64, link: &str, _: &str) -> Result<(), String> {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
@@ -208,6 +215,8 @@ fn document_arrives(h: &mut Harness, script: &Shared) {
|
||||
h.frames(3);
|
||||
}
|
||||
|
||||
/// Not sharing: one way to start (online or the local network, which also offers Can edit / Can
|
||||
/// view) and one to join, with addresses, keys and ports under Advanced.
|
||||
#[test]
|
||||
fn not_sharing_the_popover_offers_one_way_to_start_and_one_to_join() {
|
||||
let (mut h, script) = harness();
|
||||
@@ -223,9 +232,11 @@ fn not_sharing_the_popover_offers_one_way_to_start_and_one_to_join() {
|
||||
}
|
||||
let shapes = click(&mut h, &shapes, "Advanced");
|
||||
assert!(has_text(&shapes, "Publish to another server…") && !has_text(&shapes, "Port"));
|
||||
// The local network: what it means, and its own advanced part.
|
||||
assert!(!has_text(&shapes, "Can view"), "online, what the code lets people do is picked once shared");
|
||||
// The local network: what it means, what the code lets people do, and its own advanced part.
|
||||
let shapes = click(&mut h, &shapes, "Local network");
|
||||
assert!(has_text(&shapes, "People on your network join with a code. Nothing goes through a server."));
|
||||
assert!(has_text(&shapes, "Can edit") && has_text(&shapes, "Can view") && !has_text(&shapes, "Can comment"));
|
||||
assert!(has_text(&shapes, "This computer only") && has_text(&shapes, "Port") && !has_text(&shapes, "Publish to another server…"));
|
||||
assert!(!has_text(&shapes, "About this server"));
|
||||
assert_eq!(crate::live::inspect(&h.app)["local"], true);
|
||||
@@ -377,6 +388,8 @@ fn a_file_that_is_not_shared_can_be_removed_from_the_server_after_asking() {
|
||||
assert!(has_text(&shapes, "Share this file") && has_text(&shapes, "Removed from the server"));
|
||||
}
|
||||
|
||||
/// Sharing on the local network shows the longer code, copies it and its link, and Can view
|
||||
/// shows the view-only code; Advanced picks this computer only and a port.
|
||||
#[test]
|
||||
fn sharing_on_the_local_network_shows_its_longer_code() {
|
||||
let (mut h, script) = harness();
|
||||
@@ -388,15 +401,24 @@ fn sharing_on_the_local_network_shows_its_longer_code() {
|
||||
assert!(has_text(&shapes, t), "sharing shows {t:?}");
|
||||
}
|
||||
assert!(has_text(&shapes, "Anyone on your network with this code can edit the file, while this app stays open."));
|
||||
assert!(!has_text(&shapes, "Can view"), "everyone edits on the local network");
|
||||
assert!(has_text(&shapes, "Can view") && !has_text(&shapes, "Can comment"), "a view-only code too, no comment code");
|
||||
{
|
||||
let s = script.lock().unwrap();
|
||||
assert!(s.hosted[0].short && s.hosted[0].bind == figcraft_engine::live::Bind::Lan && s.hosted[0].port.is_none());
|
||||
assert!(s.hosted[0].short && s.hosted[0].view && s.hosted[0].bind == figcraft_engine::live::Bind::Lan && s.hosted[0].port.is_none());
|
||||
assert!(s.published.is_empty(), "nothing goes to a server");
|
||||
}
|
||||
let shapes = click(&mut h, &shapes, "Copy code");
|
||||
let shapes = click(&mut h, &shapes, "Copy link");
|
||||
assert_eq!(script.lock().unwrap().copied, [shown, HOST.link()]);
|
||||
assert_eq!(script.lock().unwrap().copied, [shown.clone(), HOST.link()]);
|
||||
// Can view shows the view-only code and link, and says what they let people do.
|
||||
let shapes = click(&mut h, &shapes, "Can view");
|
||||
let view = code::grouped(&VIEW.code());
|
||||
assert!(has_text(&shapes, &view) && !has_text(&shapes, &shown));
|
||||
assert!(has_text(&shapes, "Anyone on your network with this code can look at the file, not change it, while this app stays open."));
|
||||
let shapes = click(&mut h, &shapes, "Copy code");
|
||||
let shapes = click(&mut h, &shapes, "Copy link");
|
||||
assert_eq!(script.lock().unwrap().copied[2..], [view, VIEW.link()]);
|
||||
assert_eq!(script.lock().unwrap().hosted.len(), 1, "the same session");
|
||||
// Advanced: this computer only, on a port of your choosing.
|
||||
let shapes = click(&mut h, &shapes, "Stop sharing");
|
||||
let shapes = click(&mut h, &shapes, "Advanced");
|
||||
@@ -407,6 +429,27 @@ fn sharing_on_the_local_network_shows_its_longer_code() {
|
||||
assert!(s.hosted[1].bind == figcraft_engine::live::Bind::Local && s.hosted[1].port == Some(6));
|
||||
}
|
||||
|
||||
/// Can view picked before a local network share starts: the view-only code is the one shown, so
|
||||
/// the edit code isn't on the screen until Can edit is picked.
|
||||
#[test]
|
||||
fn a_local_share_can_start_for_looking_only() {
|
||||
let (mut h, script) = harness();
|
||||
let shapes = open(&mut h);
|
||||
let shapes = click(&mut h, &shapes, "Local network");
|
||||
let shapes = click(&mut h, &shapes, "Can view");
|
||||
assert_eq!(h.app.ui.live.local_access, Access::View);
|
||||
assert_eq!(h.app.session.live().status, Status::Off, "picking starts nothing");
|
||||
let shapes = click(&mut h, &shapes, "Start sharing");
|
||||
let (view, edit) = (code::grouped(&VIEW.code()), code::grouped(&HOST.code()));
|
||||
assert!(has_text(&shapes, &view) && !has_text(&shapes, &edit));
|
||||
assert!(has_text(&shapes, "Anyone on your network with this code can look at the file, not change it, while this app stays open."));
|
||||
assert_eq!(crate::live::inspect(&h.app)["share"]["access"], "view");
|
||||
assert!(script.lock().unwrap().hosted[0].view);
|
||||
// Can edit shows the edit code.
|
||||
let shapes = click(&mut h, &shapes, "Can edit");
|
||||
assert!(has_text(&shapes, &edit) && has_text(&shapes, "Anyone on your network with this code can edit the file, while this app stays open."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_code_the_link_is_shared_instead() {
|
||||
// A server from before join codes.
|
||||
|
||||
@@ -131,6 +131,7 @@ impl Harness {
|
||||
address: "127.0.0.1:47900".into(),
|
||||
network: false,
|
||||
access: figcraft_engine::live::Access::Edit,
|
||||
view: None,
|
||||
};
|
||||
self.send(vec![LiveEvent::Welcome { info, peers: vec![Peer { client_id: 1, name: "Hana".into(), color: "#9747FF".into() }] }]);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ type Shared = Arc<Mutex<Script>>;
|
||||
|
||||
struct Link(Shared);
|
||||
|
||||
/// A session you are in as `role` under `name`.
|
||||
fn info(role: Role, name: &str) -> SessionInfo {
|
||||
SessionInfo {
|
||||
role,
|
||||
@@ -61,6 +62,7 @@ fn info(role: Role, name: &str) -> SessionInfo {
|
||||
address: "192.168.1.20:47900".into(),
|
||||
network: true,
|
||||
access: Access::Edit,
|
||||
view: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-2
@@ -597,8 +597,16 @@ resuming with its offline edits, and leaving the session going back to snapshot
|
||||
`local`), so a local share is protected by 44 bits, not 128: a code is guessed over the network
|
||||
at the rate the host accepts connections, which makes 44 bits years of trying, but treat it as
|
||||
a secret for the local network, not for the internet. The invite link names the same session.
|
||||
Whoever sees the code learns the host's address. Everyone who joins edits; a host without an
|
||||
IPv4 address gets no code and shares its link.
|
||||
Whoever sees the code learns the host's address. The session has two secrets, so two codes:
|
||||
the **edit code** and a **view-only code** (the same address and port, a 44-bit secret of its
|
||||
own). The host welcomes whoever joins with the view secret as a viewer (`Access::View`): they
|
||||
see every change and everyone's presence, the app makes the file read-only for them, and the
|
||||
session refuses any batch they send anyway, as a server does for its view links. The Share
|
||||
popover picks which code shows (Can edit / Can view, also before sharing starts, so the edit
|
||||
code need never be on screen; `collab.share {access}`, whose `link` and `url` are the ones that
|
||||
go with the code shown, so a view-only share never hands out the edit secret); picking one later changes
|
||||
nothing for who already joined. There is no comment code on the local network. A host without an IPv4
|
||||
address gets no codes and shares its links.
|
||||
- **In the browser** a code joins like a link (the page asks the server over its own WebSocket,
|
||||
then opens the session's); it shares online through the server in Preferences as the desktop
|
||||
app does, and not on the local network (a page can't listen).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Collaboration parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); `figcraft-server` checks the server key by comparing SHA-256 digests, not the raw keys (#1130); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; `comment.list {mentions}` lists only the threads that @mention a name (#1415); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); an AI agent attached over MCP shows where it works (#1416); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; 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)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (a file shared on the local network can be shared to look at only: a view-only code next to the edit code, picked in the Share popover before or while sharing)
|
||||
|
||||
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. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/<key>` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). The batches kept for apps that reconnect are bounded in bytes by default (64 MiB; `max_log_mb` on `figcraft-server`), and an app further behind catches up with a snapshot (#1069). Placing a layer among its siblings is a binary search, so large pastes and reorders in one batch no longer take quadratic time (#1065). A refused batch's fixes take time linear in the batch: each deleted layer is restored once and each entry fix looked up once (#1063). Left: server archives beyond Linux | 25–55 |
|
||||
| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors (one name for live sessions and comments: Preferences ▸ Your name, also in the Share popover and the Join dialog, kept in the browser too; a rename shows at once in the sessions you're in, #1380), selection outlines, avatars (an AI agent attached over MCP also shows its avatar and an outline around the layers it last edited or selected while it works, in or out of a session, #1416), 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, comment 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, and the Join dialog then shows the join until its file is ready (Cancel), or why it failed (Try again, #1381); the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser), and shares online and publishes to a server as the desktop app does, though not on the local network (#917; the owner token of a file it puts on an open server lasts as long as the page)); `figcraft-server` share links per file with edit, comment or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; a commenter also adds, replies to, reacts to and resolves comment threads, with a "Can comment" badge and the comment tool, #1525; the server refuses their other 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; a server only a team reaches can also list the files shared from it now for everyone (`team_files`): Home ▸ Online shows that team list for the server in Preferences, a row joins its file, and its links (`figcraft://join?server=…&file=…&team=1`, or the browser build's) carry no secret and stop working with the share. 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, comment and view links with short codes; local network: the app hosts and the code carries its address, with an edit code and a view-only code, picked before or while sharing, whose holders see every change and can't make one; one field joins with a code or a link, in the popover and on the home screen, and the Join dialog then shows the join until its file is ready (Cancel), or why it failed (Try again, #1381); the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser), and shares online and publishes to a server as the desktop app does, though not on the local network (#917; the owner token of a file it puts on an open server lasts as long as the page)); `figcraft-server` share links per file with edit, comment or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; a commenter also adds, replies to, reacts to and resolves comment threads, with a "Can comment" badge and the comment tool, #1525; the server refuses their other 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; a server only a team reaches can also list the files shared from it now for everyone (`team_files`): Home ▸ Online shows that team list for the server in Preferences, a row joins its file, and its links (`figcraft://join?server=…&file=…&team=1`, or the browser build's) carry no secret and stop working with the share. 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 (an AI agent attached over MCP comments as its own name, marked Agent); 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. `comment.list` (CLI, MCP) gives each message its `mentions` (`{name, known, you}`, by the highlighting rule). 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, also in live sessions**: 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; entries for its creation and updates from main); fills, strokes, effects, layout grids, export settings, guides, interactions, annotations and Dev Mode resources merge item by item. In a live session a branch stays with its author (its ids in their own client space, far above the main file's) and merging it is one edit everyone gets; peers' edits merge against the branch's own base and conflicts resolve as offline; a view link refuses merging. Left: others in the session can't see or open your branches (the server would have to keep them); text runs conflict as a whole; a branch made offline that adds layers merges only outside a session | 1–2 |
|
||||
@@ -26,6 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-06 | minor | M9: a file shared on the local network can be shared read-only. The session the app hosts has a second secret, so a view-only code next to the edit code (same address and port, a 44-bit secret of its own); whoever joins with it is welcomed as a viewer, sees every change and presence, gets the "View only" badge and only the moving tools, and the session refuses their edits. The Share popover offers Can edit / Can view under Local network before sharing starts (the edit code then never shows) and while sharing; `collab.share {via: "local", access: "view"}` does the same (its `link` and `url`, like its `code`, are the view-only way in), and `share` in `collab.peers` lists both codes and links. No comment code on the local network. Numbers unchanged. |
|
||||
| 2026-10-31 | patch | M9, #1665: the document in a live session's welcome goes compressed (deflate) to clients whose hello announces `packed`, about seven times smaller on the wire (a synthetic file of 4,040 layers: 397 KB to 53 KB); older clients keep getting it uncompressed, and the client reads a packed document only up to the size an uncompressed welcome could have had. |
|
||||
| 2026-10-30 | minor | M9, part of #742: a team list on `figcraft-server`. With `team_files = "edit"` (or `comment`, `view`) everyone who reaches the server sees the files shared from it now (a link of theirs has a join code: Share ▸ Start sharing) and joins them by a team list link, `figcraft://join?server=…&file=…&team=1`, which carries no secret: the server hands out the file's team link while it stays shared (`ResolveTeam`, answered like a join code), and Stop sharing revokes it with the others, disconnecting whoever joined with it. Turning the list off or changing its access refuses the old team links at once. `collab.files {refresh}` asks the server in Preferences for its list (`Admin::Team`; not the public server FigCraft ships with), and Home ▸ Online shows it below your own files: a row joins its file, its menu copies the link for the app or, with the server's `web_url`, for the browser build (`<web_url>#join=…`). A browser build served beside its server names it with `<meta name="figcraft-server">`, the page's default for sharing, codes and the team list. No accounts: the list is for a server only your team reaches. Tests: `team_tests` (the server and apps over loopback), `code_tests` (links and the browser's driver), `online_tests` (the Online section), `query_tests` (the page's server). Percentages and hours unchanged |
|
||||
| 2026-10-30 | minor | M9, #1130: `figcraft-server` compares the SHA-256 digests of the presented and the kept server key instead of the raw strings, as `docs/server.md` says; the key file, `figcraft-server key` and rotation are unchanged. Numbers unchanged. |
|
||||
|
||||
Reference in New Issue
Block a user