//! Sharing with a join code against a scripted link: `collab.share` online and on the local //! network, the codes in `collab.peers`, switching what the code lets people do, stopping (online: //! the others' links go, the file and its owner stay; sharing again makes new ones), and //! `collab.join` with a code of either kind or a link (what typing tolerates, and what it says //! when it can't be a code). use std::net::Ipv4Addr; use std::sync::{Arc, Mutex}; use figcraft_collab::code::{self, Local}; use figcraft_collab::message::ClientMsg; 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, }; use crate::{EngineError, Session}; /// What the link was asked to do, and the events it hands out next (as the session started last). #[derive(Default)] struct Script { hosted: Vec, published: Vec, joined: Vec, /// What the server was asked about a file's sharing: (server, file, owner, revoke, create). asked: Vec<(String, String, String, Vec, bool)>, /// The server can't be reached. offline: bool, left: Vec, events: Vec, session: u64, } struct Link(Arc>); /// 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 }; impl LiveLink for Link { fn host(&mut self, session: u64, opts: HostOptions) -> Result { 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() }; s.hosted.push(opts); Ok(SessionInfo { role: Role::Host, me: 1, name: "Me".into(), color: "#0D99FF".into(), file_name: "Shared".into(), pages: vec![], url: link.replace("figcraft://join?host=", "ws://"), link, address: "192.168.1.20:47900".into(), network: true, access: Access::Edit, }) } fn join(&mut self, session: u64, link: &str, _: &str) -> Result<(), String> { if !link.starts_with("figcraft://join?") { return Err("that's not a live session invite".into()); } let mut s = self.0.lock().unwrap(); s.session = session; s.joined.push(link.to_string()); Ok(()) } fn publish(&mut self, session: u64, opts: PublishOptions) -> Result<(), String> { let mut s = self.0.lock().unwrap(); s.session = session; s.published.push(opts); Ok(()) } fn share(&mut self, server: &str, file: &str, owner: &str, revoke: &[String], create: bool) -> Result, String> { let mut s = self.0.lock().unwrap(); if s.offline { return Err("no answer from the server".into()); } s.asked.push((server.into(), file.into(), owner.into(), revoke.to_vec(), create)); // Each time the file is shared the server makes new links, with new codes. let n = s.asked.iter().filter(|a| a.4).count(); Ok(create.then(|| links(n, Some((&format!("NEWED1T0{n}"), &format!("NEWV1EW0{n}")))))) } fn leave(&mut self, session: u64) { self.0.lock().unwrap().left.push(session); } fn presence(&mut self, _: u64, _: &Presence) {} fn send(&mut self, _: u64, _: ClientMsg) {} fn poll(&mut self) -> Vec<(u64, Event)> { let mut s = self.0.lock().unwrap(); let session = s.session; std::mem::take(&mut s.events).into_iter().map(|e| (session, e)).collect() } } fn session() -> (Session, Arc>) { let mut s = Session::new(); let script = Arc::new(Mutex::new(Script::default())); s.live.install(Box::new(Link(script.clone()))); (s, script) } fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) } fn err(s: &mut Session, cmd: &str, p: Value) -> String { s.execute(cmd, &p).unwrap_err().to_string() } /// The links a server makes for others the `n`th time a file is shared, with their codes (a /// server from before join codes has none). fn links(n: usize, codes: Option<(&str, &str)>) -> Shared { let link = |what: &str, code: Option<&str>| SharedLink { id: format!("{what}{n}"), link: format!("figcraft://join?server=wss://share.example:443/&file=f1&token=Share{what}Share{what}0{n}"), code: code.map(str::to_string), }; Shared { edit: link("edit", codes.map(|c| c.0)), view: Some(link("view", codes.map(|c| c.1))) } } /// The file is on the server and joined: its owner's links, and what others join with (codes /// too, when given). fn on_the_server(s: &mut Session, script: &Arc>, codes: Option<(&str, &str)>) { let link = |token: &str| format!("figcraft://join?server=wss://share.example:443/&file=f1&token={token}"); let published = Published { server: "wss://share.example:443/".into(), file: "f1".into(), name: "Untitled".into(), edit: link("EditEditEditEditEdit00"), view: link("ViewViewViewViewView00"), owner: Some("OwnerOwnerOwnerOwner00".into()), expires: None, }; let info = SessionInfo { role: Role::Guest, me: 1, name: "Me".into(), color: "#0D99FF".into(), file_name: "Untitled".into(), pages: vec![], link: published.edit.clone(), url: String::new(), address: "wss://share.example:443/".into(), network: true, access: Access::Edit, }; let events = vec![Event::Published(published), Event::Shared(Ok(links(0, codes))), Event::Welcome { info, peers: vec![] }]; script.lock().unwrap().events.extend(events); s.live_poll(); } /// The file reached the server and its owner is in it, without links for others. fn on_the_server_unshared(s: &mut Session, script: &Arc>) { on_the_server(s, script, None); if let Some(sh) = s.live_mut().and_then(|l| l.share.as_mut()) { sh.links = None; } } #[test] fn the_built_in_server_is_the_default_and_can_be_changed_or_turned_off() { let (mut s, _) = session(); assert_eq!(s.prefs.publish_server, DEFAULT_SERVER); // A build may ship without one (`FIGCRAFT_DEFAULT_SERVER=`); any it ships is TLS. assert!( DEFAULT_SERVER.is_empty() || (DEFAULT_SERVER.starts_with("wss://") && DEFAULT_SERVER.ends_with('/')), "TLS, and an address the preference itself accepts" ); assert_eq!(ex(&mut s, "pref.publishServer", json!({"url": DEFAULT_SERVER}))["url"], DEFAULT_SERVER); // Another server; back to the built-in one; off. assert_eq!(ex(&mut s, "pref.publishServer", json!({"url": "wss://share.example/"}))["url"], "wss://share.example/"); let reset = ex(&mut s, "pref.publishServer", json!({})); assert_eq!((&reset["url"], &reset["default"]), (&json!(DEFAULT_SERVER), &json!(DEFAULT_SERVER))); assert_eq!(ex(&mut s, "pref.publishServer", json!({"url": ""}))["url"], ""); // Off is saved as off (an empty address isn't left out, or the default would come back). let saved = serde_json::to_value(&s.prefs).unwrap(); assert_eq!(saved["publish_server"], ""); let loaded: crate::Prefs = serde_json::from_value(saved).unwrap(); assert!(loaded.publish_server.is_empty()); // Preferences saved before there was a default get it. let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap(); assert_eq!(old.publish_server, DEFAULT_SERVER); } #[test] fn sharing_online_publishes_to_the_server_in_preferences_and_shows_its_code() { let (mut s, script) = session(); ex(&mut s, "pref.publishServer", json!({"url": "wss://share.example/"})); let r = ex(&mut s, "collab.share", json!({})); assert_eq!( (&r["status"], &r["share"]["via"], &r["share"]["access"], &r["code"]), (&json!("connecting"), &json!("online"), &json!("edit"), &Value::Null) ); assert_eq!(r["readOnly"], true, "as for any publish, until the file is there"); { let sc = script.lock().unwrap(); let [p] = sc.published.as_slice() else { panic!("published {}", sc.published.len()) }; assert_eq!((p.server.as_str(), p.key.as_deref(), p.name.as_str(), p.share), ("wss://share.example/", None, "Untitled", true)); assert!(sc.hosted.is_empty()); } on_the_server(&mut s, &script, Some(("K7M2QX9TD", "4FHJ8R2WN"))); let r = ex(&mut s, "collab.peers", json!({})); assert_eq!((&r["status"], &r["code"]), (&json!("live"), &json!("K7M-2QX-9TD"))); assert_eq!( (&r["share"]["shared"], &r["share"]["editCode"], &r["share"]["viewCode"]), (&json!(true), &json!("K7M-2QX-9TD"), &json!("4FH-J8R-2WN")) ); // What others get are links of their own, not the owner's. let (edit_link, own) = (r["share"]["editLink"].as_str().unwrap(), r["published"]["edit"].as_str().unwrap()); assert!(edit_link.contains("ShareeditShareedit00") && edit_link != own && r["link"] == own, "{edit_link}"); assert!(s.message.is_none(), "the popover shows the code: no toast about publishing"); // Asking again only switches which code is the one to give out. let r = ex(&mut s, "collab.share", json!({"access": "view"})); assert_eq!((&r["code"], &r["share"]["access"]), (&json!("4FH-J8R-2WN"), &json!("view"))); assert_eq!(ex(&mut s, "collab.share", json!({"via": "online"}))["code"], "4FH-J8R-2WN", "nothing new starts"); assert_eq!(ex(&mut s, "collab.share", json!({"access": "edit"}))["code"], "K7M-2QX-9TD"); assert_eq!(script.lock().unwrap().published.len(), 1); assert!(err(&mut s, "collab.share", json!({"via": "local"})).contains("stop sharing it first")); assert!(err(&mut s, "collab.share", json!({"access": "admin"})).contains("`access` must be")); // Stopping while the server can't be reached stops nothing, and says so. script.lock().unwrap().offline = true; assert!(err(&mut s, "collab.stop", json!({})).contains("couldn't stop sharing")); assert_eq!(ex(&mut s, "collab.peers", json!({}))["code"], "K7M-2QX-9TD"); script.lock().unwrap().offline = false; // Stopping: the server is asked, as the file's owner, to revoke the others' links. The file // stays there and its owner stays in it: nothing is left, nothing is removed. assert_eq!(ex(&mut s, "collab.stop", json!({})), json!({"ended": false, "shared": false})); { let sc = script.lock().unwrap(); assert!(sc.left.is_empty(), "the owner stays in the file"); let want = ( "wss://share.example/".to_string(), "f1".to_string(), "OwnerOwnerOwnerOwner00".to_string(), vec!["edit0".to_string(), "view0".to_string()], false, ); assert_eq!(sc.asked, [want]); } let r = ex(&mut s, "collab.peers", json!({})); assert_eq!((&r["status"], &r["syncing"].is_boolean(), &r["code"]), (&json!("live"), &true, &Value::Null)); assert_eq!( r["share"], json!({"via": "online", "access": "edit", "shared": false, "editCode": null, "viewCode": null, "editLink": null, "viewLink": null}) ); assert!(r["published"]["owner"].is_string(), "still the owner's file on the server"); assert!(s.message.as_deref().unwrap_or_default().contains("stays on the server")); // Sharing again: new links and codes for the same file, nothing published twice. let r = ex(&mut s, "collab.share", json!({})); assert_eq!((&r["code"], &r["share"]["shared"], &r["share"]["viewCode"]), (&json!("NEW-ED1-T01"), &json!(true), &json!("NEW-V1E-W01"))); assert!(r["share"]["editLink"].as_str().unwrap().contains("ShareeditShareedit01")); { let sc = script.lock().unwrap(); assert_eq!(sc.published.len(), 1); assert_eq!(sc.asked[1], ("wss://share.example/".to_string(), "f1".to_string(), "OwnerOwnerOwnerOwner00".to_string(), vec![], true)); } assert_eq!(ex(&mut s, "collab.share", json!({}))["code"], "NEW-ED1-T01", "shared already: nothing more is asked"); assert_eq!(script.lock().unwrap().asked.len(), 2); // Stopping again revokes the new ones; then `collab.stop` on a file that isn't shared leaves // its session like any other (the file stays on the server). ex(&mut s, "collab.stop", json!({})); assert_eq!(script.lock().unwrap().asked[2].3, ["edit1", "view1"]); assert_eq!(ex(&mut s, "collab.stop", json!({}))["ended"], true); { let sc = script.lock().unwrap(); assert_eq!((sc.left.clone(), sc.asked.len()), (vec![1], 3)); } let r = ex(&mut s, "collab.peers", json!({})); assert_eq!((&r["status"], &r["code"], &r["share"]), (&json!("off"), &Value::Null, &Value::Null)); } #[test] fn a_server_without_join_codes_leaves_the_links() { let (mut s, script) = session(); ex(&mut s, "collab.share", json!({"via": "online", "server": "wss://old.example/?key=KeyKeyKeyKeyKeyKeyKey0", "name": "Poster"})); { let sc = script.lock().unwrap(); assert_eq!((sc.published[0].server.as_str(), sc.published[0].name.as_str()), ("wss://old.example/?key=KeyKeyKeyKeyKeyKeyKey0", "Poster")); } on_the_server(&mut s, &script, None); let r = ex(&mut s, "collab.peers", json!({})); assert_eq!((&r["status"], &r["code"], &r["share"]["editCode"]), (&json!("live"), &Value::Null, &Value::Null)); assert!(r["share"]["editLink"].as_str().unwrap().contains("token="), "the links are what there is to share"); assert!(!r.to_string().contains("KeyKey"), "the server's key is nowhere in the summary"); // Codes that aren't codes are not shown either. script.lock().unwrap().events.push(Event::Shared(Ok(links(0, Some(("