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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LvG6E2VLhC9d5v8VSsf4ng
This commit is contained in:
Filip StrajnarandClaude Opus 5.5 committed 2026-11-06 19:12:40 +01:00
1 parent a05201e21f
commit b779e46d47
19 files changed
+99 -61

No files matched your search

+1 -1
View File
@@ -91,7 +91,7 @@ fn info() -> SessionInfo {
address: SERVER.into(),
network: true,
access: Access::Edit,
view_link: None,
view: None,
}
}
+28 -18
View File
@@ -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);
}
+1 -1
View File
@@ -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());
+3 -3
View File
@@ -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)
+14 -8
View File
@@ -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<Mutex<Script>>, 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.
+1 -1
View File
@@ -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,
}
}
+1 -1
View File
@@ -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,
}
}
+9 -8
View File
@@ -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())),
}
}
+1 -1
View File
@@ -151,7 +151,7 @@ pub(super) fn share(s: &mut Session, p: &Value) -> Result<Value> {
// 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 });
+1 -1
View File
@@ -175,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\" (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
),
+4 -3
View File
@@ -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": 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)),
+24 -4
View File
@@ -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<String>,
/// 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:
@@ -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,
}
+1 -1
View File
@@ -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> {
+1 -1
View File
@@ -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);
+4 -4
View File
@@ -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();
+1 -1
View File
@@ -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() }] }]);
}
+1 -1
View File
@@ -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,
}
}
+2 -2
View File
@@ -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,
+1 -1
View File
@@ -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 (`<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. |