From a05201e21fd835b308fc1ce641dc468c6b0e4887 Mon Sep 17 00:00:00 2001 From: Filip Strajnar Date: Fri, 6 Nov 2026 18:45:44 +0100 Subject: [PATCH] M9: a local view-only share's link is the view link, not the edit link `collab.share {via: "local", access: "view"}` showed the view-only code but gave the session's own invite link as `link`, and that link edits: whoever joined through it could change the host's file. A local share's `link`, top level and in `sessions`, is now the one that goes with the code shown, and it follows `collab.share {access}`. Online shares keep the owner's own link there, as before. Regression test: `code_tests::a_local_view_share_s_link_only_lets_people_look` joins through that link over loopback and is welcomed as a viewer whose edit never reaches the host. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LvG6E2VLhC9d5v8VSsf4ng --- crates/collab-net/src/code_tests.rs | 28 ++++++++++++++++++++++++++ crates/engine/src/live_share_tests.rs | 6 +++++- crates/engine/src/sharing/live.rs | 11 ++++++++++ crates/engine/src/sharing/live/join.rs | 2 +- crates/engine/src/sharing/live/sync.rs | 4 ++-- docs/collab-protocol.md | 5 +++-- docs/collaboration-parity.md | 2 +- 7 files changed, 51 insertions(+), 7 deletions(-) diff --git a/crates/collab-net/src/code_tests.rs b/crates/collab-net/src/code_tests.rs index a4becfdcc..2ef05bc40 100644 --- a/crates/collab-net/src/code_tests.rs +++ b/crates/collab-net/src/code_tests.rs @@ -159,6 +159,34 @@ 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. +#[test] +fn a_local_view_share_s_link_only_lets_people_look() { + let (mut host, mut viewer) = (session("Hana"), session("Ada")); + 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. + 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()))); + assert_eq!(shared["sessions"][0]["link"], shared["link"]); + // Picking the edit code makes the link the edit one 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"]); + assert_eq!(host.execute("collab.stop", &json!({})).unwrap()["ended"], true); +} + /// 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() } diff --git a/crates/engine/src/live_share_tests.rs b/crates/engine/src/live_share_tests.rs index 76deed757..ee0588ad0 100644 --- a/crates/engine/src/live_share_tests.rs +++ b/crates/engine/src/live_share_tests.rs @@ -380,9 +380,12 @@ fn sharing_on_the_local_network_shows_a_code_that_carries_the_address() { assert!(h.short && h.view && h.bind == Bind::Lan && h.port.is_none(), "the local network unless told otherwise"); } // 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!(ex(&mut s, "collab.share", json!({"access": "edit"}))["code"], json!(code::grouped(&HOST.code()))); + assert_eq!((&r["link"], &r["sessions"][0]["link"]), (&json!(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!(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); @@ -390,6 +393,7 @@ 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"); 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/sharing/live.rs b/crates/engine/src/sharing/live.rs index 45d56776c..034137db5 100644 --- a/crates/engine/src/sharing/live.rs +++ b/crates/engine/src/sharing/live.rs @@ -147,6 +147,17 @@ 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> { + match &self.share { + Some(sh) if sh.via == Via::Local => sh.link(), + _ => self.info.as_ref().map(|i| i.link.as_str()), + } + } + /// Stop following. False when you weren't following anyone. fn unfollow(&mut self) -> bool { self.followed = None; diff --git a/crates/engine/src/sharing/live/join.rs b/crates/engine/src/sharing/live/join.rs index 994c4d6a9..255471217 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 session's own invite link (it 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 (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", false, share ), diff --git a/crates/engine/src/sharing/live/sync.rs b/crates/engine/src/sharing/live/sync.rs index 79f693731..f3ffa5615 100644 --- a/crates/engine/src/sharing/live/sync.rs +++ b/crates/engine/src/sharing/live/sync.rs @@ -295,7 +295,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_link(), "peers": o.peers.len(), }) }) @@ -313,7 +313,7 @@ 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()), + "link": l.invite_link(), "url": info.map(|i| i.url.as_str()), "address": info.map(|i| i.address.as_str()), "network": info.map(|i| i.network), diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md index e95ba91f0..8d476d760 100644 --- a/docs/collab-protocol.md +++ b/docs/collab-protocol.md @@ -603,8 +603,9 @@ 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}`); picking one later changes nothing for - who already joined. There is no comment code on the local network. A host without an IPv4 + 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 + 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 diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index 51fab74e9..cfab7553a 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, 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`, 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-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. |