From b779e46d47ae43635c4c8705a8f69b69c980bbed Mon Sep 17 00:00:00 2001 From: Filip Strajnar Date: Fri, 6 Nov 2026 19:12:40 +0100 Subject: [PATCH] M9: a local view-only share's url is the view URL, not the edit URL The previous fix made a local share's `link` follow the code shown, but its top-level `url`, the session's WebSocket URL, still carried the edit token: `collab.join {link: url}` from a view-only share joined with edit access and the edit reached the host. The link layer now hands over the view way in as a link and its URL (`SessionInfo::view`, a `WayIn`, in place of `view_link`), and `collab.peers` gives a local share's `link` and `url` from the way in for the code shown (`Live::invite`). Online shares are unchanged. Regression test: `code_tests::a_local_view_share_s_link_and_url_only_let_people_look` joins through the `link` and then the `url` over loopback; both are welcomed as viewers whose edits never reach the host. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LvG6E2VLhC9d5v8VSsf4ng --- apps/figcraft/src/recovery_online_tests.rs | 2 +- crates/collab-net/src/code_tests.rs | 46 +++++++++++++--------- crates/collab-net/src/join.rs | 2 +- crates/collab-net/src/link.rs | 6 +-- crates/engine/src/live_share_tests.rs | 22 +++++++---- crates/engine/src/live_tests.rs | 2 +- crates/engine/src/online_tests.rs | 2 +- crates/engine/src/sharing/live.rs | 17 ++++---- crates/engine/src/sharing/live/host.rs | 2 +- crates/engine/src/sharing/live/join.rs | 2 +- crates/engine/src/sharing/live/sync.rs | 7 ++-- crates/engine/src/sharing/live/types.rs | 28 +++++++++++-- crates/ui-egui/src/live_docs_tests.rs | 2 +- crates/ui-egui/src/live_server_tests.rs | 2 +- crates/ui-egui/src/live_share_tests.rs | 8 ++-- crates/ui-egui/src/live_tests.rs | 2 +- crates/ui-egui/src/online_tests.rs | 2 +- docs/collab-protocol.md | 4 +- docs/collaboration-parity.md | 2 +- 19 files changed, 99 insertions(+), 61 deletions(-) diff --git a/apps/figcraft/src/recovery_online_tests.rs b/apps/figcraft/src/recovery_online_tests.rs index bc3a1d20d..29b821f81 100644 --- a/apps/figcraft/src/recovery_online_tests.rs +++ b/apps/figcraft/src/recovery_online_tests.rs @@ -91,7 +91,7 @@ fn info() -> SessionInfo { address: SERVER.into(), network: true, access: Access::Edit, - view_link: None, + view: None, } } diff --git a/crates/collab-net/src/code_tests.rs b/crates/collab-net/src/code_tests.rs index 2ef05bc40..ca7cc51fb 100644 --- a/crates/collab-net/src/code_tests.rs +++ b/crates/collab-net/src/code_tests.rs @@ -159,31 +159,41 @@ fn a_local_view_code_lets_people_look_and_not_edit() { 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` is the one that goes with the code shown: started for looking -/// only, whoever joins through it can't edit, and the session's line in `sessions` says the same. +/// 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_only_lets_people_look() { - let (mut host, mut viewer) = (session("Hana"), session("Ada")); +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(); - let link = shared["link"].as_str().unwrap().to_string(); - viewer.execute("collab.join", &json!({"link": link})).unwrap(); - pump(&mut [&mut host, &mut viewer], "the link'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)), "joined with {link}"); - 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"), "{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"); - // The link and the code name the same way in, wherever the session is listed. + 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 the link the edit one too. + // 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"])); - assert_ne!(edit["link"], shared["link"]); + 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); } diff --git a/crates/collab-net/src/join.rs b/crates/collab-net/src/join.rs index 6c913e009..f86409b40 100644 --- a/crates/collab-net/src/join.rs +++ b/crates/collab-net/src/join.rs @@ -229,7 +229,7 @@ pub(crate) fn take(mut m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mu address: inv.server.display(), network: !inv.loopback(), access, - view_link: None, + view: None, }; seen.peers = peers.clone(); seen.info = Some(info.clone()); diff --git a/crates/collab-net/src/link.rs b/crates/collab-net/src/link.rs index b2a39ad72..35bbe7188 100644 --- a/crates/collab-net/src/link.rs +++ b/crates/collab-net/src/link.rs @@ -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}; @@ -165,7 +165,7 @@ impl LiveLink for NetLink { core: figcraft_collab::Limits::default(), }; let host = Host::start(cfg, inbox.clone()).map_err(|e| format!("can't listen on {ip}: {e}"))?; - let view_link = view_token.map(|v| Invite::new(host.addr().to_string(), file.clone(), v).link()); + let view = view_token.map(|v| Invite::new(host.addr().to_string(), file.clone(), v)).map(|v| WayIn { link: v.link(), url: v.url() }); let invite = Invite::new(host.addr().to_string(), file, token); let info = SessionInfo { role: Role::Host, @@ -179,7 +179,7 @@ impl LiveLink for NetLink { address: host.addr().to_string(), network: !ip.is_loopback(), access: Access::Edit, - view_link, + view, }; self.started(session, Active::Host(host), inbox); Ok(info) diff --git a/crates/engine/src/live_share_tests.rs b/crates/engine/src/live_share_tests.rs index ee0588ad0..9c86e0900 100644 --- a/crates/engine/src/live_share_tests.rs +++ b/crates/engine/src/live_share_tests.rs @@ -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}; @@ -39,6 +39,11 @@ const HOST: Local = Local { ip: Ipv4Addr::new(192, 168, 1, 20), port: 47_900, se /// 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. @@ -47,7 +52,7 @@ impl LiveLink for Link { 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_link = opts.view.then(|| VIEW.link()); + let view = opts.view.then(|| WayIn { link: VIEW.link(), url: mock_url(&VIEW.link()) }); s.hosted.push(opts); Ok(SessionInfo { role: Role::Host, @@ -56,12 +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_link, + view, }) } fn join(&mut self, session: u64, link: &str, _: &str) -> Result<(), String> { @@ -152,7 +157,7 @@ fn on_the_server(s: &mut Session, script: &Arc>, codes: Option<(&s address: "wss://share.example:443/".into(), network: true, access: Access::Edit, - view_link: None, + 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); @@ -383,9 +388,9 @@ fn sharing_on_the_local_network_shows_a_code_that_carries_the_address() { // 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["sessions"][0]["link"]), (&json!(VIEW.link()), &json!(VIEW.link()))); + 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"]), (&json!(code::grouped(&HOST.code())), &json!(HOST.link()))); + 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); @@ -393,7 +398,8 @@ fn sharing_on_the_local_network_shows_a_code_that_carries_the_address() { // 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["sessions"][0]["link"]), (&json!(VIEW.link()), &json!(VIEW.link())), "the edit link isn't handed out"); + 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. diff --git a/crates/engine/src/live_tests.rs b/crates/engine/src/live_tests.rs index 2d2f504f7..c9863ae74 100644 --- a/crates/engine/src/live_tests.rs +++ b/crates/engine/src/live_tests.rs @@ -104,7 +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_link: None, + view: None, } } diff --git a/crates/engine/src/online_tests.rs b/crates/engine/src/online_tests.rs index 3f8c6a293..98d655f06 100644 --- a/crates/engine/src/online_tests.rs +++ b/crates/engine/src/online_tests.rs @@ -150,7 +150,7 @@ fn info(role: Role, name: &str, link: &str) -> SessionInfo { address: SERVER.into(), network: true, access: Access::Edit, - view_link: None, + view: None, } } diff --git a/crates/engine/src/sharing/live.rs b/crates/engine/src/sharing/live.rs index 034137db5..6e5b0515a 100644 --- a/crates/engine/src/sharing/live.rs +++ b/crates/engine/src/sharing/live.rs @@ -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,14 +147,15 @@ impl Live { self.info.as_ref().map(|i| i.role) } - /// The session's invite link as `collab.peers` gives it. A file shared on the local network - /// gives the link that goes with the code shown: the session's own link is the edit way in, - /// and a view-only share must not hand it out. Otherwise the session's own link (online, the - /// owner's: `share` has the links for others). - pub fn invite_link(&self) -> Option<&str> { + /// 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 => sh.link(), - _ => self.info.as_ref().map(|i| i.link.as_str()), + 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())), } } diff --git a/crates/engine/src/sharing/live/host.rs b/crates/engine/src/sharing/live/host.rs index 1b3ebb6b0..916da6949 100644 --- a/crates/engine/src/sharing/live/host.rs +++ b/crates/engine/src/sharing/live/host.rs @@ -151,7 +151,7 @@ pub(super) fn share(s: &mut Session, p: &Value) -> Result { // 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_link.as_deref().map(local_link), + view: i.view.as_ref().map(|v| local_link(&v.link)), comment: None, }); live.share = Some(Share { via, server, access, links }); diff --git a/crates/engine/src/sharing/live/join.rs b/crates/engine/src/sharing/live/join.rs index 255471217..9e7d07902 100644 --- a/crates/engine/src/sharing/live/join.rs +++ b/crates/engine/src/sharing/live/join.rs @@ -175,7 +175,7 @@ pub fn specs() -> Vec { 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\" (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 (local: the one that goes with `code`, so a view-only code's link lets 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", + "{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 ), diff --git a/crates/engine/src/sharing/live/sync.rs b/crates/engine/src/sharing/live/sync.rs index f3ffa5615..0dcf5eed2 100644 --- a/crates/engine/src/sharing/live/sync.rs +++ b/crates/engine/src/sharing/live/sync.rs @@ -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 = 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": o.invite_link(), + "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": l.invite_link(), - "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)), diff --git a/crates/engine/src/sharing/live/types.rs b/crates/engine/src/sharing/live/types.rs index c83f1254c..431934d56 100644 --- a/crates/engine/src/sharing/live/types.rs +++ b/crates/engine/src/sharing/live/types.rs @@ -123,9 +123,29 @@ pub struct SessionInfo { /// comment link). pub access: Access, /// A session this app hosts with a way in for looking only ([`HostOptions::view`]): its - /// invite link, which carries the view secret (`None`: everyone who joins edits, or you - /// joined). - pub view_link: Option, + /// invite link and WebSocket URL, which carry the view secret (`None`: everyone who joins + /// edits, or you joined). + pub view: Option, +} + +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: @@ -395,7 +415,7 @@ pub struct HostOptions { /// 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_link`]). + /// refuses theirs ([`SessionInfo::view`]). pub view: bool, } diff --git a/crates/ui-egui/src/live_docs_tests.rs b/crates/ui-egui/src/live_docs_tests.rs index 7e104dac7..bc1f25f30 100644 --- a/crates/ui-egui/src/live_docs_tests.rs +++ b/crates/ui-egui/src/live_docs_tests.rs @@ -32,7 +32,7 @@ impl LiveLink for Hosting { address, network: false, access: Access::Edit, - view_link: None, + view: None, }) } fn join(&mut self, _: u64, _: &str, _: &str) -> Result<(), String> { diff --git a/crates/ui-egui/src/live_server_tests.rs b/crates/ui-egui/src/live_server_tests.rs index c7e1c2e38..745a8e93d 100644 --- a/crates/ui-egui/src/live_server_tests.rs +++ b/crates/ui-egui/src/live_server_tests.rs @@ -44,7 +44,7 @@ pub(crate) fn joined(h: &mut Harness, access: Access) { address: "127.0.0.1:47900".into(), network: false, access, - view_link: None, + view: None, }; h.send(vec![LiveEvent::Welcome { info, peers: vec![] }, LiveEvent::Sync(w)]); h.frames(2); diff --git a/crates/ui-egui/src/live_share_tests.rs b/crates/ui-egui/src/live_share_tests.rs index b1c6556df..8ef118249 100644 --- a/crates/ui-egui/src/live_share_tests.rs +++ b/crates/ui-egui/src/live_share_tests.rs @@ -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; @@ -72,7 +72,7 @@ fn info(role: Role, link: &str) -> SessionInfo { address: "192.168.1.20:47900".into(), network: true, access: Access::Edit, - view_link: None, + view: None, } } @@ -83,9 +83,9 @@ impl LiveLink for Link { let mut s = self.0.lock().unwrap(); s.session = session; let (link, view) = if opts.port == Some(6) { (PLAIN.to_string(), PLAIN.replace("AbCd", "View")) } else { (HOST.link(), VIEW.link()) }; - let view_link = opts.view.then_some(view); + let view = opts.view.then(|| WayIn { url: view.replace("figcraft://join?host=", "ws://"), link: view }); s.hosted.push(opts); - Ok(SessionInfo { view_link, ..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(); diff --git a/crates/ui-egui/src/live_tests.rs b/crates/ui-egui/src/live_tests.rs index ab8735287..5fc809959 100644 --- a/crates/ui-egui/src/live_tests.rs +++ b/crates/ui-egui/src/live_tests.rs @@ -131,7 +131,7 @@ impl Harness { address: "127.0.0.1:47900".into(), network: false, access: figcraft_engine::live::Access::Edit, - view_link: None, + view: None, }; self.send(vec![LiveEvent::Welcome { info, peers: vec![Peer { client_id: 1, name: "Hana".into(), color: "#9747FF".into() }] }]); } diff --git a/crates/ui-egui/src/online_tests.rs b/crates/ui-egui/src/online_tests.rs index bc41618e9..9d1ad163a 100644 --- a/crates/ui-egui/src/online_tests.rs +++ b/crates/ui-egui/src/online_tests.rs @@ -62,7 +62,7 @@ fn info(role: Role, name: &str) -> SessionInfo { address: "192.168.1.20:47900".into(), network: true, access: Access::Edit, - view_link: None, + view: None, } } diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md index 8d476d760..03d57c84e 100644 --- a/docs/collab-protocol.md +++ b/docs/collab-protocol.md @@ -603,8 +603,8 @@ resuming with its offline edits, and leaving the session going back to snapshot 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` is the one that goes with - the code shown, so a view-only share never hands out the edit link); picking one later changes + 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, diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index cfab7553a..ef541f3bf 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -26,7 +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`, like its `code`, is 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-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 (`#join=…`). A browser build served beside its server names it with ``, 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. |