diff --git a/AGENTS.md b/AGENTS.md index b3f31490b9f622339ead049042bc83b041cf54fa..8264ca6d5bed92cc8bda7c9673153951a533a3d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ novel UI kit, each new platform port is extremely lightweight. | Path | Owns | Depends on | | --- | --- | --- | | `crates/onestore` | The file format: revision stores (`.one`, `.onetoc2`), the page model, ops, the commit protocol. No network, no SQLite, no `unsafe`. | none | -| `crates/notebook` | From editor to disk or share: discovery, notebook structure, sessions, the SQLite replica, sync and merging, conflict pages, the embedded SMB client (feature `smb`). | onestore | +| `crates/notebook` | From editor to disk or share: discovery, notebook structure, sessions, the SQLite replica, sync and merging, conflict pages, the embedded SMB client (feature `smb`), live presence between peers (feature `live`). | onestore | | `crates/draw` | The wgpu renderer that page and chrome both paint through, and the text-editing core (keys, chords, carets) they share. | none | | `crates/canvas` | The page: editor, OneNote-faithful layout, page scene, interaction, the page's accessibility tree. | onestore, draw | | `crates/ui` | The immediate-mode interface kit and OneNote's chrome controls. Knows nothing of notebooks. | draw | diff --git a/Cargo.lock b/Cargo.lock index 691bd7d2e613a220daea9f7f1f9d3417ede5e5b8..a45e40ee46a458129d14eaa17fe7a28f94f27e50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -908,6 +908,33 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rand_core 0.10.1", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "data-url" version = "0.3.2" @@ -1131,6 +1158,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "file-guard" version = "0.2.0" @@ -1164,6 +1197,15 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "spin", +] + [[package]] name = "foldhash" version = "0.2.0" @@ -1459,6 +1501,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.13.0" @@ -1625,6 +1676,16 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" +[[package]] +name = "if-addrs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "image" version = "0.25.10" @@ -1913,6 +1974,20 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" +[[package]] +name = "mdns-sd" +version = "0.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1067efe2aadc6967f84c95977dca910e98f3edfd6403efc8fa8508d289ce012d" +dependencies = [ + "fastrand", + "flume", + "if-addrs", + "mio", + "socket-pktinfo", + "socket2", +] + [[package]] name = "memchr" version = "2.8.3" @@ -1937,6 +2012,26 @@ dependencies = [ "autocfg", ] +[[package]] +name = "minicbor" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c12b4033ffaa92fbf9df03df38d19324f52bad130dd223f811734a8006dd2d69" +dependencies = [ + "minicbor-derive", +] + +[[package]] +name = "minicbor-derive" +version = "0.19.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84f5ad8dfe10176465fe33f19ecfcf08783ba66630fdb40b2e4542547c1046f0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1964,6 +2059,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -2089,6 +2185,8 @@ dependencies = [ "hmac", "js-sys", "libc", + "mdns-sd", + "minicbor", "nix", "onestore", "rsqlite-vfs", @@ -2097,6 +2195,7 @@ dependencies = [ "serde_json", "sha2", "smb2", + "spake2", "tempfile", "thiserror 2.0.20", "tokio", @@ -3602,6 +3701,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "socket-pktinfo" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612942246d0cc239cfd83af1dfd39be47f649208a3524e5e9da651910128e0ac" +dependencies = [ + "libc", + "socket2", + "windows-sys 0.61.2", +] + [[package]] name = "socket2" version = "0.6.5" @@ -3612,6 +3722,19 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spake2" +version = "0.5.0-pre.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5601a88f45d069ad786f75c4fb13e7f127b0aadda913fb2cd65948d3c9a561" +dependencies = [ + "curve25519-dalek", + "getrandom 0.4.3", + "hkdf", + "rand_core 0.10.1", + "sha2", +] + [[package]] name = "spellbook" version = "0.4.2" @@ -3622,6 +3745,15 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + [[package]] name = "spirv" version = "0.4.0+sdk-1.4.341.0" diff --git a/crates/canvas/src/editor.rs b/crates/canvas/src/editor.rs index 064daf6ac2c276a344e1eb5d8eb77994fd7ce261..9d8cba33d6c5166af3cc86d94c73d1f0fe25517b 100644 --- a/crates/canvas/src/editor.rs +++ b/crates/canvas/src/editor.rs @@ -529,6 +529,23 @@ impl TextOutline { .into() } + /// The caret at `focus`, in the outline's coordinates. + pub fn caret_at( + &self, + focus: TextPosition, + affinity: Affinity, + width: f32, + ) -> Result { + let paragraph = self.paragraph_layout(focus.paragraph)?; + let cursor = paragraph.cursor(focus.offset, affinity)?; + let mut rect = paragraph.text.caret(cursor, width); + rect.x0 += f64::from(paragraph.origin[0]); + rect.x1 += f64::from(paragraph.origin[0]); + rect.y0 += f64::from(paragraph.origin[1]); + rect.y1 += f64::from(paragraph.origin[1]); + Ok(rect) + } + /// Highlight rectangles of `selection` in the outline, in its coordinates. pub fn range_rects(&self, selection: Selection) -> Result, EditError> { let [anchor, focus] = selection.positions; @@ -2989,14 +3006,7 @@ impl CanvasEditor { affinity: Affinity, width: f32, ) -> Result { - let paragraph = self.active_outline().paragraph_layout(focus.paragraph)?; - let cursor = paragraph.cursor(focus.offset, affinity)?; - let mut rect = paragraph.text.caret(cursor, width); - rect.x0 += f64::from(paragraph.origin[0]); - rect.x1 += f64::from(paragraph.origin[0]); - rect.y0 += f64::from(paragraph.origin[1]); - rect.y1 += f64::from(paragraph.origin[1]); - Ok(rect) + self.active_outline().caret_at(focus, affinity, width) } pub fn move_selection( diff --git a/crates/notebook/Cargo.toml b/crates/notebook/Cargo.toml index 649b79c1f3c5143a8bd8283118c4381788342d4d..11fb79992c50189bb798ca5191c0f8a901f09c10 100644 --- a/crates/notebook/Cargo.toml +++ b/crates/notebook/Cargo.toml @@ -6,6 +6,9 @@ publish = false [features] smb = ["dep:smb2", "dep:tokio"] +# Live presence: peers found on the network, met through a shared secret, over an encrypted +# stream (resources/live-share.md). +live = ["dep:mdns-sd", "dep:minicbor", "dep:spake2"] [dependencies] onestore = { path = "../onestore" } @@ -23,6 +26,9 @@ aes-gcm = "0.11.1" base64 = { version = "0.23.1", default-features = false, features = ["std"] } getrandom = "0.4.3" hmac = "0.13.0" +mdns-sd = { version = "0.21.4", default-features = false, optional = true } +minicbor = { version = "2.3.0", features = ["derive", "alloc"], optional = true } +spake2 = { version = "=0.5.0-pre.0", features = ["getrandom"], optional = true } # OneNote packages (.onepkg) are cabinet files. cab = "0.6" # std's clock where there is one; the browser's on wasm32-unknown-unknown, whose std has none. diff --git a/crates/notebook/README.md b/crates/notebook/README.md index 1ce771bbc5895ddccb5036a54215a8b49122d1b9..db6126f899eddf38c68d54fb0c934ed25e609243 100644 --- a/crates/notebook/README.md +++ b/crates/notebook/README.md @@ -529,6 +529,19 @@ Device and simulator builds link for iOS. Native acceptance uses disposable OneNote 2010 clients and Samba; it does not establish on-device execution or physical power-loss durability. +## Live presence (feature `live`) + +`live::Live::start(hello, room, reach, notify)` listens on a TCP port and, with a `Reach`, +advertises `_snowbound._tcp` by mDNS on every network or on loopback alone, connecting to the +peers in the same `Room` that it finds: a notebook's identity, or a code typed on both +(`Room::Code("4-violet-otter")`). `connect(address)` meets a peer discovery did not find. +Peers meet through SPAKE2 on the room's secret, then every frame is AES-256-GCM under the keys +it agreed: a message kind and a CBOR map (`live::wire`). A reader skips kinds and map keys it +doesn't know, so later versions add both freely. `set_presence` says which section, page and +caret this end has (text object and UTF-16 offset, as ops address text); a connection sends +only the newest. `peers()` lists each connected peer's `Hello` (name, picture) and presence, +and `notify` runs whenever that changes. Dropping the `Live` leaves. + ## Queue measurement `cargo run -p notebook --release --example queue_scale -- NEW_DIRECTORY 1000` diff --git a/crates/notebook/src/lib.rs b/crates/notebook/src/lib.rs index 49ddb44369cede88a5564f65a1d04c1abfd27794..7160fc053dfeb6b7ba143a017ab8111957fa9b15 100644 --- a/crates/notebook/src/lib.rs +++ b/crates/notebook/src/lib.rs @@ -4,6 +4,8 @@ pub mod discover; pub mod fs; +#[cfg(feature = "live")] +pub mod live; #[cfg(feature = "smb")] pub mod smb; diff --git a/crates/notebook/src/live.rs b/crates/notebook/src/live.rs new file mode 100644 index 0000000000000000000000000000000000000000..a6bfeb8275cfbd90d6db066c47026104e2725a0a --- /dev/null +++ b/crates/notebook/src/live.rs @@ -0,0 +1,394 @@ +//! Live presence: who else has the notebook open, the page they are on and their caret, +//! straight from one Snowbound to another. Peers find each other with mDNS +//! (`_snowbound._tcp`) and meet through a secret both hold, a notebook's identity or a code +//! typed on both, which SPAKE2 turns into the keys every frame after the opening is sealed +//! with. The lower peer id connects; each connection has a thread reading and one writing. + +pub mod wire; +pub use wire::{Caret, Guid, Hello, Presence, Spot}; + +use mdns_sd::{IfKind, ServiceDaemon, ServiceEvent, ServiceInfo}; +use sha2::{Digest, Sha256}; +use std::{ + collections::BTreeMap, + io, + net::{IpAddr, Ipv4Addr, Shutdown, SocketAddr, TcpListener, TcpStream}, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, AtomicU64, Ordering}, + mpsc, + }, + thread, + time::Duration, +}; +use wire::{Sealer, Side, kind}; + +const SERVICE: &str = "_snowbound._tcp.local."; +/// How long a connection may be quiet before a ping, and before the peer counts as gone. +const PING: Duration = Duration::from_secs(15); +const GONE: Duration = Duration::from_secs(45); +const OPENING: Duration = Duration::from_secs(5); + +/// The secret peers meet through. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Room { + /// Everyone with the notebook: its table of contents' file identity, which only its files + /// hold. + Notebook([u8; 16]), + /// A code typed on both: `7-violet-otter`, whose number names it on the network and whose + /// words only the two people know. + Code(String), +} + +impl Room { + /// What names the room in the clear: a hash of a notebook's identity, a code's number. + fn tag(&self) -> String { + match self { + Room::Notebook(id) => hex(&Sha256::digest([&b"Snowbound room "[..], id].concat())[..8]), + Room::Code(code) => format!("code-{}", code.split('-').next().unwrap_or_default()), + } + } + + fn secret(&self) -> Vec { + match self { + Room::Notebook(id) => id.to_vec(), + Room::Code(code) => code.trim().to_lowercase().into_bytes(), + } + } +} + +/// Where peers are looked for. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Reach { + /// Every network this computer is on. + Network, + /// This computer alone, for two copies of the app side by side. + Loopback, +} + +#[derive(Clone, Debug)] +pub struct Peer { + pub hello: Arc, + /// None until the peer first says where it is. + pub presence: Option, +} + +/// Presence on the network while it lives; dropping it leaves. +pub struct Live { + shared: Arc, + address: SocketAddr, + daemon: Option, +} + +struct Shared { + me: Hello, + tag: String, + secret: Vec, + state: Mutex, + notify: Box, + stopped: AtomicBool, + connections: AtomicU64, +} + +#[derive(Default)] +struct State { + presence: Presence, + /// Counts changes to `presence`, so a writer sends only the newest. + generation: u64, + peers: BTreeMap<[u8; 16], Link>, +} + +struct Link { + connection: u64, + peer: Peer, + wake: mpsc::Sender<()>, + stream: TcpStream, +} + +impl Live { + /// Starts listening as `me` in `room`, advertised and looked for where `reach` says, or + /// not at all with `None`, leaving peers to `connect`. `notify` runs on a network thread + /// whenever `peers` changes. + pub fn start( + me: Hello, + room: &Room, + reach: Option, + notify: impl Fn() + Send + Sync + 'static, + ) -> io::Result { + let host = match reach { + Some(Reach::Network) => IpAddr::V4(Ipv4Addr::UNSPECIFIED), + Some(Reach::Loopback) | None => IpAddr::V4(Ipv4Addr::LOCALHOST), + }; + let listener = TcpListener::bind((host, 0))?; + let mut address = listener.local_addr()?; + if address.ip().is_unspecified() { + address.set_ip(IpAddr::V4(Ipv4Addr::LOCALHOST)); + } + let shared = Arc::new(Shared { + me, + tag: room.tag(), + secret: room.secret(), + state: Mutex::default(), + notify: Box::new(notify), + stopped: AtomicBool::new(false), + connections: AtomicU64::new(0), + }); + let accepting = Arc::clone(&shared); + thread::Builder::new() + .name("live accept".into()) + .spawn(move || { + for stream in listener.incoming() { + if accepting.stopped.load(Ordering::Acquire) { + return; + } + if let Ok(stream) = stream { + let shared = Arc::clone(&accepting); + thread::spawn(move || shared.run(stream, Side::Responder)); + } + } + })?; + let daemon = match reach { + Some(reach) => { + Some(advertise(&shared, reach, address.port()).map_err(io::Error::other)?) + } + None => None, + }; + Ok(Live { + shared, + address, + daemon, + }) + } + + /// Where this end listens. + pub fn address(&self) -> SocketAddr { + self.address + } + + /// Connects to a peer at `address` that discovery did not find. + pub fn connect(&self, address: SocketAddr) { + let shared = Arc::clone(&self.shared); + thread::spawn(move || shared.dial(address)); + } + + /// Says where this end is now; peers hear only the newest of quick changes. + pub fn set_presence(&self, presence: Presence) { + let mut state = self.shared.state.lock().unwrap(); + if state.presence == presence { + return; + } + state.presence = presence; + state.generation += 1; + for link in state.peers.values() { + let _ = link.wake.send(()); + } + } + + /// The peers connected now, by id. + pub fn peers(&self) -> Vec { + let state = self.shared.state.lock().unwrap(); + state.peers.values().map(|link| link.peer.clone()).collect() + } +} + +impl Drop for Live { + fn drop(&mut self) { + self.shared.stopped.store(true, Ordering::Release); + if let Some(daemon) = &self.daemon { + let _ = daemon.shutdown(); + } + // Wakes the accepting thread to see it has stopped. + let _ = TcpStream::connect_timeout(&self.address, OPENING); + for link in self.shared.state.lock().unwrap().peers.values() { + let _ = link.stream.shutdown(Shutdown::Both); + } + } +} + +/// Advertises `shared` on `port` and connects to the peers in its room that discovery finds +/// with a higher id than its own, which leave the connecting to it. +fn advertise(shared: &Arc, reach: Reach, port: u16) -> mdns_sd::Result { + let daemon = ServiceDaemon::new()?; + let id = hex(&shared.me.peer); + let properties = [ + ("v", "1"), + ("room", shared.tag.as_str()), + ("peer", id.as_str()), + ]; + let host = format!("snowbound-{id}.local."); + let info = match reach { + Reach::Network => { + ServiceInfo::new(SERVICE, &id, &host, "", port, &properties[..])?.enable_addr_auto() + } + Reach::Loopback => { + daemon.disable_interface(IfKind::All)?; + daemon.enable_interface(IfKind::LoopbackV4)?; + ServiceInfo::new( + SERVICE, + &id, + &host, + IpAddr::V4(Ipv4Addr::LOCALHOST), + port, + &properties[..], + )? + } + }; + daemon.register(info)?; + let found = daemon.browse(SERVICE)?; + let shared = Arc::clone(shared); + thread::Builder::new() + .name("live discovery".into()) + .spawn(move || { + while let Ok(event) = found.recv() { + let ServiceEvent::ServiceResolved(service) = event else { + continue; + }; + let (Some(room), Some(peer)) = ( + service.get_property_val_str("room"), + service.get_property_val_str("peer"), + ) else { + continue; + }; + if room != shared.tag || peer <= id.as_str() || shared.knows(peer) { + continue; + } + let mut addresses: Vec = + service.addresses.iter().map(|ip| ip.to_ip_addr()).collect(); + addresses.sort_by_key(|ip| (!ip.is_ipv4(), !ip.is_loopback())); + if let Some(ip) = addresses.first() { + let address = SocketAddr::new(*ip, service.port); + let shared = Arc::clone(&shared); + thread::spawn(move || shared.dial(address)); + } + } + }) + .map_err(|error| mdns_sd::Error::Msg(error.to_string()))?; + Ok(daemon) +} + +impl Shared { + fn knows(&self, peer: &str) -> bool { + let state = self.state.lock().unwrap(); + state.peers.keys().any(|id| hex(id) == peer) + } + + fn dial(self: Arc, address: SocketAddr) { + if let Ok(stream) = TcpStream::connect_timeout(&address, OPENING) { + self.run(stream, Side::Initiator); + } + } + + /// Meets the peer at the other end of `stream`, then reads from it until it goes. + fn run(self: Arc, mut stream: TcpStream, side: Side) { + if self.stopped.load(Ordering::Acquire) { + return; + } + let met = (|| { + stream.set_read_timeout(Some(OPENING))?; + stream.set_nodelay(true)?; + let (mut send, mut receive) = wire::open(&mut stream, side, &self.tag, &self.secret)?; + send.send(&mut stream, kind::HELLO, &self.me)?; + let (first, body) = receive.receive(&mut stream)?; + if first != kind::HELLO { + return Err(io::Error::new(io::ErrorKind::InvalidData, "No hello")); + } + let hello: Hello = minicbor::decode(&body) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "A malformed hello"))?; + stream.set_read_timeout(Some(GONE))?; + Ok((send, receive, hello)) + })(); + let (send, mut receive, hello) = match met { + Ok(met) => met, + Err(error) => { + eprintln!("Live: no meeting with {:?}: {error}", stream.peer_addr()); + return; + } + }; + let peer = hello.peer; + let connection = self.connections.fetch_add(1, Ordering::Relaxed); + let (wake, woken) = mpsc::channel(); + { + let mut state = self.state.lock().unwrap(); + if peer == self.me.peer || state.peers.contains_key(&peer) { + return; + } + let Ok(writing) = stream.try_clone() else { + return; + }; + let _ = wake.send(()); + state.peers.insert( + peer, + Link { + connection, + peer: Peer { + hello: Arc::new(hello), + presence: None, + }, + wake, + stream: writing, + }, + ); + } + (self.notify)(); + if let Ok(writing) = stream.try_clone() { + let shared = Arc::clone(&self); + thread::spawn(move || shared.write(writing, send, woken)); + } + while let Ok((message, body)) = receive.receive(&mut stream) { + if message != kind::PRESENCE { + continue; + } + let Ok(presence) = minicbor::decode::(&body) else { + break; + }; + if let Some(link) = self.state.lock().unwrap().peers.get_mut(&peer) { + link.peer.presence = Some(presence); + } + (self.notify)(); + } + let _ = stream.shutdown(Shutdown::Both); + let mut state = self.state.lock().unwrap(); + if state + .peers + .get(&peer) + .is_some_and(|link| link.connection == connection) + { + state.peers.remove(&peer); + drop(state); + (self.notify)(); + } + } + + /// Sends the newest presence whenever woken, and a ping when quiet. + fn write(&self, mut stream: TcpStream, mut send: Sealer, woken: mpsc::Receiver<()>) { + let mut sent = None; + loop { + let result = match woken.recv_timeout(PING) { + Ok(()) => { + let presence = { + let state = self.state.lock().unwrap(); + if sent == Some(state.generation) { + continue; + } + sent = Some(state.generation); + state.presence.clone() + }; + send.send(&mut stream, kind::PRESENCE, &presence) + } + Err(mpsc::RecvTimeoutError::Timeout) => send.send(&mut stream, kind::PING, &()), + Err(mpsc::RecvTimeoutError::Disconnected) => return, + }; + if result.is_err() { + let _ = stream.shutdown(Shutdown::Both); + return; + } + } + } +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +#[cfg(test)] +mod tests; diff --git a/crates/notebook/src/live/tests.rs b/crates/notebook/src/live/tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..2d5efa137c81df365a89a7adab3aed54cefd9d3d --- /dev/null +++ b/crates/notebook/src/live/tests.rs @@ -0,0 +1,146 @@ +use super::*; +use std::time::Instant; + +fn hello(name: &str) -> Hello { + Hello::new(name.into(), Some(vec![1, 2, 3])).unwrap() +} + +/// Waits until `done` holds of `live`'s peers. +fn until(live: &Live, done: impl Fn(&[Peer]) -> bool) -> Vec { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let peers = live.peers(); + if done(&peers) { + return peers; + } + assert!(Instant::now() < deadline, "peers stayed {peers:?}"); + thread::sleep(Duration::from_millis(20)); + } +} + +fn caret(offset: u32) -> Presence { + let spot = Spot { + text: Guid { + guid: [7; 16], + n: 3, + }, + offset, + }; + Presence { + section: Some([5; 16]), + page: Some(Guid { + guid: [6; 16], + n: 1, + }), + caret: Some(Caret { + anchor: spot, + focus: spot, + }), + } +} + +/// Two ends of one code meet, greet each other by name and picture, hear each other's newest +/// caret, and see the other leave. +#[test] +fn peers_meet_and_follow_presence() { + let room = Room::Code("7-violet-otter".into()); + let ada = Live::start(hello("Ada"), &room, None, || {}).unwrap(); + let grace = Live::start(hello("Grace"), &room, None, || {}).unwrap(); + ada.set_presence(caret(1)); + ada.connect(grace.address()); + let seen = until(&grace, |peers| { + peers.iter().any(|peer| peer.presence.is_some()) + }); + assert_eq!(seen[0].hello.name, "Ada"); + assert_eq!(seen[0].hello.picture.as_deref(), Some(&[1, 2, 3][..])); + assert_eq!(seen[0].presence, Some(caret(1))); + until(&ada, |peers| { + peers.len() == 1 && peers[0].hello.name == "Grace" + }); + for offset in 2..20 { + ada.set_presence(caret(offset)); + } + until(&grace, |peers| peers[0].presence == Some(caret(19))); + drop(ada); + until(&grace, <[Peer]>::is_empty); +} + +/// A peer holding another code never meets: its first frame does not open. +#[test] +fn another_code_never_meets() { + let ada = Live::start( + hello("Ada"), + &Room::Code("7-violet-otter".into()), + None, + || {}, + ) + .unwrap(); + let mallory = Live::start( + hello("Mallory"), + &Room::Code("7-violet-ocelot".into()), + None, + || {}, + ) + .unwrap(); + mallory.connect(ada.address()); + thread::sleep(Duration::from_millis(500)); + assert!(ada.peers().is_empty() && mallory.peers().is_empty()); +} + +/// A field or a kind a later version adds is skipped by this one. +#[test] +fn later_fields_and_kinds_are_skipped() { + #[derive(Encode)] + #[cbor(map)] + struct Later { + #[cbor(n(0), with = "minicbor::bytes")] + section: Option<[u8; 16]>, + #[n(9)] + mood: String, + } + use minicbor::Encode; + let body = minicbor::to_vec(Later { + section: Some([5; 16]), + mood: "curious".into(), + }) + .unwrap(); + let presence: Presence = minicbor::decode(&body).unwrap(); + assert_eq!( + presence, + Presence { + section: Some([5; 16]), + ..Presence::default() + } + ); + + let room = Room::Code("4-quiet-heron".into()); + let grace = Live::start(hello("Grace"), &room, None, || {}).unwrap(); + // A later version: it greets, says something new, then where it is. + let later = thread::spawn(move || { + let mut stream = TcpStream::connect(grace.address()).unwrap(); + let (mut send, mut receive) = + wire::open(&mut stream, Side::Initiator, &room.tag(), &room.secret()).unwrap(); + send.send(&mut stream, kind::HELLO, &hello("Later")) + .unwrap(); + receive.receive(&mut stream).unwrap(); + send.send(&mut stream, 999, &"a chat message").unwrap(); + send.send(&mut stream, kind::PRESENCE, &caret(4)).unwrap(); + until(&grace, |peers| { + peers + .first() + .is_some_and(|peer| peer.presence == Some(caret(4))) + }); + }); + later.join().unwrap(); +} + +/// Two ends of one notebook find each other by mDNS on this computer's loopback. +#[test] +#[ignore = "multicasts mDNS on the loopback interface"] +fn peers_find_each_other_on_loopback() { + let room = Room::Notebook([9; 16]); + let ada = Live::start(hello("Ada"), &room, Some(Reach::Loopback), || {}).unwrap(); + let grace = Live::start(hello("Grace"), &room, Some(Reach::Loopback), || {}).unwrap(); + until(&ada, |peers| peers.len() == 1); + until(&grace, |peers| peers.len() == 1); +} diff --git a/crates/notebook/src/live/wire.rs b/crates/notebook/src/live/wire.rs new file mode 100644 index 0000000000000000000000000000000000000000..ecc5af93e9ded61624ef4b94eb1d73bd6c5e6d5a --- /dev/null +++ b/crates/notebook/src/live/wire.rs @@ -0,0 +1,267 @@ +//! What peers say to each other: an opening in the clear that meets through the secret both +//! hold (SPAKE2), then frames sealed under the keys it agreed, each a message kind and a CBOR +//! map. A later version adds kinds and fields; a reader skips the kinds and fields it doesn't +//! know, so every version speaks to every other. + +use aes_gcm::{Aes256Gcm, KeyInit, aead::Aead}; +use hmac::{Hmac, Mac}; +use minicbor::{Decode, Encode}; +use sha2::Sha256; +use spake2::{Ed25519Group, Identity, Password, Spake2}; +use std::io::{self, Read, Write}; + +/// The opening's version. Frames after it never change shape; they grow by kinds and fields. +pub const VERSION: u16 = 1; +/// The largest block either side reads. +const MOST: usize = 16 << 20; + +/// Message kinds. +pub mod kind { + pub const HELLO: u16 = 1; + /// Keeps a quiet connection open; it says nothing else. + pub const PING: u16 = 2; + pub const PRESENCE: u16 = 16; +} + +/// The kinds this version reads, as `Hello::kinds` lists them. +pub const KNOWN: &[u16] = &[kind::HELLO, kind::PING, kind::PRESENCE]; + +/// The first message each way, and the only one with a name and a picture. +#[derive(Clone, Debug, PartialEq, Encode, Decode)] +#[cbor(map)] +pub struct Hello { + /// Random for each run of the app. + #[cbor(n(0), with = "minicbor::bytes")] + pub peer: [u8; 16], + #[n(1)] + pub name: String, + /// A PNG of the person, at most 96 pixels a side. + #[cbor(n(2), with = "minicbor::bytes")] + pub picture: Option>, + /// The app and its version, for diagnostics. + #[n(3)] + pub app: String, + /// The message kinds the sender reads; a request waits for its kind to be listed. + #[n(4)] + pub kinds: Vec, +} + +impl Hello { + /// A hello from a new peer, named `name`, reading the kinds this version reads. + pub fn new(name: String, picture: Option>) -> io::Result { + let mut peer = [0; 16]; + getrandom::fill(&mut peer).map_err(|_| io::Error::other("System random source failed"))?; + Ok(Self { + peer, + name, + picture, + app: format!("Snowbound {}", env!("CARGO_PKG_VERSION")), + kinds: KNOWN.to_vec(), + }) + } +} + +/// Where someone is: the section and page they have open and their caret on it. +#[derive(Clone, Debug, Default, PartialEq, Encode, Decode)] +#[cbor(map)] +pub struct Presence { + /// The section file's identity (its header's guidFile). + #[cbor(n(0), with = "minicbor::bytes")] + pub section: Option<[u8; 16]>, + /// The page's object space. + #[n(1)] + pub page: Option, + #[n(2)] + pub caret: Option, +} + +/// A selection, collapsed where `anchor` is `focus`. +#[derive(Clone, Copy, Debug, PartialEq, Encode, Decode)] +#[cbor(map)] +pub struct Caret { + #[n(0)] + pub anchor: Spot, + #[n(1)] + pub focus: Spot, +} + +/// A place in a text object, in UTF-16 code units, as ops address text. +#[derive(Clone, Copy, Debug, PartialEq, Encode, Decode)] +#[cbor(map)] +pub struct Spot { + #[n(0)] + pub text: Guid, + #[n(1)] + pub offset: u32, +} + +/// An `ExGuid`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Encode, Decode)] +#[cbor(array)] +pub struct Guid { + #[cbor(n(0), with = "minicbor::bytes")] + pub guid: [u8; 16], + #[n(1)] + pub n: u32, +} + +impl From for Guid { + fn from(id: onestore::ExGuid) -> Self { + Self { + guid: id.guid, + n: id.n, + } + } +} + +impl From for onestore::ExGuid { + fn from(id: Guid) -> Self { + Self { + guid: id.guid, + n: id.n, + } + } +} + +/// The opening, sent in the clear by the side that connected and answered by the other. +#[derive(Encode, Decode)] +#[cbor(map)] +struct Open { + #[n(0)] + version: u16, + /// The room it means, as discovery names it. + #[n(1)] + room: String, + #[cbor(n(2), with = "minicbor::bytes")] + pake: Vec, +} + +/// One direction's AEAD key and the count of frames sealed under it, which is each frame's nonce. +pub struct Sealer { + cipher: Aes256Gcm, + count: u64, +} + +impl Sealer { + fn new(key: &[u8], purpose: &[u8]) -> Self { + let mut mac = as hmac::KeyInit>::new_from_slice(key).expect("any key length"); + mac.update(purpose); + Self { + cipher: Aes256Gcm::new_from_slice(&mac.finalize().into_bytes()).expect("a 32-byte key"), + count: 0, + } + } + + fn nonce(&mut self) -> [u8; 12] { + let mut nonce = [0; 12]; + nonce[4..].copy_from_slice(&self.count.to_be_bytes()); + self.count += 1; + nonce + } + + /// Writes message `kind` holding `body`. + pub fn send( + &mut self, + to: &mut impl Write, + kind: u16, + body: &impl Encode<()>, + ) -> io::Result<()> { + let mut clear = kind.to_be_bytes().to_vec(); + minicbor::encode(body, &mut clear).map_err(io::Error::other)?; + let nonce = self.nonce(); + let sealed = self + .cipher + .encrypt(&nonce.into(), clear.as_slice()) + .map_err(|_| io::Error::other("A frame could not be sealed"))?; + write_block(to, &sealed) + } + + /// Reads the next message: its kind and body. + pub fn receive(&mut self, from: &mut impl Read) -> io::Result<(u16, Vec)> { + let sealed = read_block(from)?; + let nonce = self.nonce(); + let mut clear = self + .cipher + .decrypt(&nonce.into(), sealed.as_slice()) + .map_err(|_| invalid("A frame does not open under the agreed key"))?; + let body = clear.split_off(2.min(clear.len())); + let kind = u16::from_be_bytes(clear.try_into().map_err(|_| invalid("An empty frame"))?); + Ok((kind, body)) + } +} + +/// Which end of the connection this is. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Side { + Initiator, + Responder, +} + +/// Meets the peer at the other end of `stream` in `room` through `secret`: the sealers to +/// send and to receive with. A peer holding another secret goes unnoticed here; its first +/// frame then fails to open. +pub fn open( + stream: &mut (impl Read + Write), + side: Side, + room: &str, + secret: &[u8], +) -> io::Result<(Sealer, Sealer)> { + let password = Password::new(secret); + let [initiator, responder] = [b"initiator", b"responder"] + .map(|role| Identity::new(&[&role[..], room.as_bytes()].concat())); + let (pake, message) = match side { + Side::Initiator => Spake2::::start_a(&password, &initiator, &responder), + Side::Responder => Spake2::::start_b(&password, &initiator, &responder), + }; + let ours = Open { + version: VERSION, + room: room.into(), + pake: message, + }; + if side == Side::Initiator { + write_block(stream, &minicbor::to_vec(&ours).map_err(io::Error::other)?)?; + } + let theirs: Open = + minicbor::decode(&read_block(stream)?).map_err(|_| invalid("A malformed opening"))?; + if theirs.version != VERSION || theirs.room != room { + return Err(invalid("The peer means another room or version")); + } + if side == Side::Responder { + write_block(stream, &minicbor::to_vec(&ours).map_err(io::Error::other)?)?; + } + let key = pake + .finish(&theirs.pake) + .map_err(|_| invalid("A malformed key exchange"))?; + let [from_initiator, from_responder] = [ + Sealer::new(&key, b"Snowbound live v1 initiator"), + Sealer::new(&key, b"Snowbound live v1 responder"), + ]; + Ok(match side { + Side::Initiator => (from_initiator, from_responder), + Side::Responder => (from_responder, from_initiator), + }) +} + +fn write_block(to: &mut impl Write, bytes: &[u8]) -> io::Result<()> { + let length = u32::try_from(bytes.len()) + .ok() + .filter(|length| *length as usize <= MOST) + .ok_or_else(|| invalid("A frame too large to send"))?; + to.write_all(&[&length.to_be_bytes()[..], bytes].concat()) +} + +fn read_block(from: &mut impl Read) -> io::Result> { + let mut length = [0; 4]; + from.read_exact(&mut length)?; + let length = u32::from_be_bytes(length) as usize; + if length > MOST { + return Err(invalid("A frame too large to read")); + } + let mut bytes = vec![0; length]; + from.read_exact(&mut bytes)?; + Ok(bytes) +} + +fn invalid(message: &'static str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message) +} diff --git a/crates/snowbound/Cargo.toml b/crates/snowbound/Cargo.toml index 162acefe89b17c21ca48a7251877daffeb557ff9..570b3256d76eb3df5eb1a03bd5581ce03c13490f 100644 --- a/crates/snowbound/Cargo.toml +++ b/crates/snowbound/Cargo.toml @@ -8,6 +8,9 @@ publish = false default = ["wgpu"] # Paints through wgpu; without it through OpenGL 2.1, for Mac OS X 10.6. wgpu = ["draw/wgpu", "dep:wgpu"] +# Live presence: the others with a notebook open, their pages and carets, met over the +# network (resources/live-share.md). Runs only where SNOWBOUND_LIVE asks. +live = ["notebook/live"] [dependencies] canvas = { path = "../canvas", features = ["interaction", "pdf"] } diff --git a/crates/snowbound/src/conflict_render.rs b/crates/snowbound/src/conflict_render.rs index 1920946e5548f70788e3770972b8908c8f96ada2..01ff951c1d9a8b5f47dedbcc44f15cb6ca1313cd 100644 --- a/crates/snowbound/src/conflict_render.rs +++ b/crates/snowbound/src/conflict_render.rs @@ -65,6 +65,7 @@ fn layout(ui: &mut Ui, session: &Session, menu: bool) -> Vec { §ion, session, [&HashSet::new(); 2], + &HashMap::new(), (0.0, false), None, None, diff --git a/crates/snowbound/src/live.rs b/crates/snowbound/src/live.rs new file mode 100644 index 0000000000000000000000000000000000000000..c4b18a3dcdf690e2a28ae9aab3f6c0db42627bba --- /dev/null +++ b/crates/snowbound/src/live.rs @@ -0,0 +1,472 @@ +//! Live presence in the window (feature `live`, on where `SNOWBOUND_LIVE` is `network` or +//! `loopback`): the others with the notebook open as avatars beside the search box, a dot on +//! the tab of the page each has open, and their carets on the page, each in a colour of their +//! own. They meet through the notebook's identity, or through `SNOWBOUND_LIVE_CODE` where it +//! is set (`resources/live-share.md`). + +use crate::{Command, Session, State, TAB_ROW, page, platform}; +use canvas::{document::TextPosition, editor::TextOutline}; +use notebook::live::{self, Caret, Hello, Peer, Reach, Room, Spot}; +use onestore::ExGuid; +use std::{collections::HashMap, sync::OnceLock}; +use ui::{Flags, Spec, children, fill, fit, px}; + +/// The side of the picture sent, in pixels. +const PICTURE: u32 = 96; +const AVATAR: f32 = 22.0; +/// The name flag above a caret. +const FLAG: f32 = 15.0; + +/// The room joined and the people met there. +pub(crate) struct Peers { + room: Room, + /// None where joining failed, which is tried again only for another room. + live: Option, + /// Each peer's picture as drawn, cut to a circle, decoded once. + pictures: HashMap<[u8; 16], Option>, +} + +fn reach() -> Option { + match std::env::var("SNOWBOUND_LIVE").ok()?.as_str() { + "network" => Some(Reach::Network), + "loopback" => Some(Reach::Loopback), + _ => None, + } +} + +impl State { + /// Joins the open notebook's room, leaving any other, and says where this window is. + pub(crate) fn follow_peers(&mut self) { + let Some(reach) = reach() else { + return; + }; + let room = match std::env::var("SNOWBOUND_LIVE_CODE") { + Ok(code) => Some(Room::Code(code)), + Err(_) => self + .session + .as_ref() + .and_then(|session| session.library.catalog()?.toc.as_ref()) + .map(|toc| Room::Notebook(toc.file_id)), + }; + if self.peers.as_ref().map(|peers| &peers.room) != room.as_ref() { + self.peers = room.map(|room| { + let redraw = self.redraw.clone(); + // The account's picture goes with the account's name, not another chosen in Options. + let picture = (self.author == platform::user_name()) + .then(|| account_picture().clone()) + .flatten(); + let live = Hello::new(self.author.clone(), picture) + .and_then(|me| { + live::Live::start(me, &room, Some(reach), move || redraw.wake_by_ref()) + }) + .inspect_err(|error| eprintln!("Live presence: {error}")) + .ok(); + Peers { + room, + live, + pictures: HashMap::new(), + } + }); + } + if let Some(live) = self.peers.as_ref().and_then(|peers| peers.live.as_ref()) { + live.set_presence(self.presence()); + } + } + + /// The open section and page, and the caret where it is in stored text. + fn presence(&self) -> live::Presence { + let Some(session) = &self.session else { + return live::Presence::default(); + }; + let outline = self.view.editor.active_outline(); + let [anchor, focus] = self + .view + .editor + .selection() + .positions + .map(|position| spot(outline, position)); + live::Presence { + section: section(session), + page: Some(session.space.into()), + caret: anchor + .zip(focus) + .map(|(anchor, focus)| Caret { anchor, focus }), + } + } + + fn connected(&self) -> Vec { + self.peers + .as_ref() + .and_then(|peers| peers.live.as_ref()) + .map(live::Live::peers) + .unwrap_or_default() + } + + /// The colours of the peers on each page of the open section. + pub(crate) fn peer_pages(&self) -> HashMap> { + let mut pages: HashMap> = HashMap::new(); + let Some(session) = &self.session else { + return pages; + }; + for peer in self.connected() { + if let Some(presence) = peer + .presence + .filter(|presence| presence.section == section(session)) + && let Some(page) = presence.page + { + pages + .entry(page.into()) + .or_default() + .push(color(&peer.hello.peer)); + } + } + pages + } + + /// The others with the notebook open, as avatars leftward from the search box: a click + /// opens the page someone has open in this section. + pub(crate) fn avatars(&mut self) { + let peers = self.connected(); + if peers.is_empty() { + return; + } + self.ui.open( + "peers", + Spec { + size: [children(), px(TAB_ROW)], + pad: [6.0, (TAB_ROW - AVATAR) / 2.0], + gap: 4.0, + ..Spec::default() + }, + ); + for peer in peers.iter().rev() { + let hello = &peer.hello; + let picture = self.peers.as_mut().and_then(|peers| { + peers + .pictures + .entry(hello.peer) + .or_insert_with(|| hello.picture.as_deref().and_then(circle)) + .clone() + }); + let avatar = self.ui.open( + hello.peer, + Spec { + flags: Flags::CLICKABLE, + size: [px(AVATAR); 2], + fill: Some(color(&hello.peer)), + radius: AVATAR / 2.0, + pad: [2.0, 2.0], + role: Some(accesskit::Role::Button), + ..Spec::default() + }, + ); + match &picture { + Some(image) => self.ui.leaf( + "picture", + Spec { + size: [fill(), fill()], + image: Some(image), + ..Spec::default() + }, + ), + None => self.ui.leaf( + "initials", + Spec { + size: [fill(), fill()], + text: Some(&initials(&hello.name)), + font_size: Some(9.0), + bold: true, + center: true, + color: Some([1.0; 4]), + ..Spec::default() + }, + ), + }; + self.ui.close(); + if let Some(node) = self.ui.access(avatar) { + node.set_label(hello.name.as_str()); + } + let place = self.place_of(peer); + ui::popup::tooltip(&mut self.ui, &hello.name, "", place.as_deref()); + if self.ui.signal(avatar).clicked + && let Some(session) = &self.session + && let Some(presence) = peer.presence.as_ref() + && presence.section == section(session) + && let Some(page) = presence.page + { + self.commands.push(Command::OpenPage(page.into())); + } + } + self.ui.close(); + } + + /// The page, or the section, `peer` has open. + fn place_of(&self, peer: &Peer) -> Option { + let session = self.session.as_ref()?; + let presence = peer.presence.as_ref()?; + if presence.section == section(session) { + let page = ExGuid::from(presence.page?); + let (_, title, _) = session.pages.iter().find(|(space, ..)| *space == page)?; + return Some(if title.is_empty() { + "Untitled page".into() + } else { + title.clone() + }); + } + let tab = session.tabs.iter().find(|tab| { + session.library.section_identity(&tab.path) == presence.section + && presence.section.is_some() + })?; + Some(format!("In {}", tab.name)) + } + + /// Each peer's caret on the open page with a flag naming them, and what they have + /// selected, as boxes floating in the page's box. + pub(crate) fn peer_carets(&mut self) { + let Some(session) = &self.session else { + return; + }; + let Some([left, top, right, bottom]) = self.ui.rect(page()) else { + return; + }; + let here = (section(session), Some(session.space.into())); + let scale = self.ui.scale(); + let viewport = self.view.viewport; + // A point on the page in points from the page box's corner. + let shown = |[x, y]: [f32; 2]| { + [ + (x * viewport.scale + viewport.origin[0]) / scale, + (y * viewport.scale + viewport.origin[1]) / scale, + ] + }; + for peer in self.connected() { + let Some(caret) = peer + .presence + .filter(|presence| (presence.section, presence.page) == here) + .and_then(|presence| presence.caret) + else { + continue; + }; + let Some((outline, focus)) = find(&self.view.editor, caret.focus) else { + continue; + }; + let color = color(&peer.hello.peer); + let [x, y] = outline.origin(); + if let Some((_, anchor)) = + find(&self.view.editor, caret.anchor).filter(|(other, _)| other.id == outline.id) + { + let rects = outline + .range_rects([anchor, focus].into()) + .unwrap_or_default(); + for (index, rect) in rects.into_iter().enumerate() { + let [x0, y0] = shown([rect.x0 as f32 + x, rect.y0 as f32 + y]); + let [x1, y1] = shown([rect.x1 as f32 + x, rect.y1 as f32 + y]); + self.ui.leaf( + (peer.hello.peer, "selection", index), + Spec { + flags: Flags::FLOAT, + position: [x0, y0], + size: [px(x1 - x0), px(y1 - y0)], + fill: Some([color[0], color[1], color[2], 0.25]), + ..Spec::default() + }, + ); + } + } + let Ok(rect) = outline.caret_at(focus, parley::Affinity::Downstream, 0.0) else { + continue; + }; + let [x0, y0] = shown([rect.x0 as f32 + x, rect.y0 as f32 + y]); + let [_, y1] = shown([rect.x0 as f32 + x, rect.y1 as f32 + y]); + if !(0.0..right - left).contains(&x0) || y1 < 0.0 || y0 > bottom - top { + continue; + } + self.ui.leaf( + (peer.hello.peer, "caret"), + Spec { + flags: Flags::FLOAT, + position: [x0 - 1.0, y0], + size: [px(2.0), px(y1 - y0)], + fill: Some(color), + ..Spec::default() + }, + ); + let name = peer + .hello + .name + .split_whitespace() + .next() + .unwrap_or("Someone"); + self.ui.leaf( + (peer.hello.peer, "flag"), + Spec { + flags: Flags::FLOAT, + position: [x0 - 1.0, y0 - FLAG], + size: [fit(), px(FLAG)], + text: Some(name), + font_size: Some(10.0), + bold: true, + color: Some([1.0; 4]), + fill: Some(color), + radius: 3.0, + pad: [4.0, 0.0], + ..Spec::default() + }, + ); + } + } +} + +/// The open section's file identity. +fn section(session: &Session) -> Option<[u8; 16]> { + session + .library + .section_identity(&session.tabs[session.tab].path) +} + +/// `position` in `outline` as ops address it: its text object and offset. +fn spot(outline: &TextOutline, position: TextPosition) -> Option { + let text = outline + .document() + .text_nodes() + .nth(position.paragraph)? + .text()?; + Some(Spot { + text: text.id.into(), + offset: position.offset, + }) +} + +/// The outline on the page holding `spot`, and where in it. +fn find(editor: &canvas::editor::CanvasEditor, spot: Spot) -> Option<(&TextOutline, TextPosition)> { + let text = ExGuid::from(spot.text); + editor.visible_outlines().find_map(|outline| { + let paragraph = outline + .document() + .text_nodes() + .position(|node| node.text().is_some_and(|object| object.id == text))?; + Some(( + outline, + TextPosition { + paragraph, + offset: spot.offset, + }, + )) + }) +} + +/// A peer's colour, the same in every window that shows them. +fn color(peer: &[u8; 16]) -> [f32; 4] { + const COLORS: [[u8; 3]; 8] = [ + [0x1a, 0x73, 0xe8], + [0xd9, 0x30, 0x25], + [0x18, 0x80, 0x38], + [0xe3, 0x74, 0x00], + [0x93, 0x34, 0xe6], + [0x00, 0x89, 0x7b], + [0xd0, 0x1b, 0x8c], + [0x80, 0x5a, 0x2e], + ]; + let [red, green, blue] = COLORS[usize::from(peer[0]) % COLORS.len()]; + draw::srgb(red, green, blue) +} + +/// The first letters of a name's first and last words. +fn initials(name: &str) -> String { + let words: Vec<&str> = name.split_whitespace().collect(); + let first = |word: Option<&&str>| word.and_then(|word| word.chars().next()); + [ + first(words.first()), + first(words.last()).filter(|_| words.len() > 1), + ] + .into_iter() + .flatten() + .flat_map(char::to_uppercase) + .collect() +} + +/// `picture`'s middle square, cut to a circle. +fn circle(picture: &[u8]) -> Option { + let image = draw::RasterImage::decode(picture, [PICTURE; 2]).ok()?; + let [width, height] = image.size(); + let side = width.min(height); + let [dx, dy] = [(width - side) / 2, (height - side) / 2]; + let radius = side as f32 / 2.0; + let mut pixels = Vec::with_capacity((side * side * 4) as usize); + for y in 0..side { + for x in 0..side { + let at = (((y + dy) * width + x + dx) * 4) as usize; + let pixel = &image.pixels()[at..at + 4]; + let distance = (x as f32 + 0.5 - radius).hypot(y as f32 + 0.5 - radius); + let coverage = (radius - distance + 0.5).clamp(0.0, 1.0); + pixels.extend_from_slice(&pixel[..3]); + pixels.push((f32::from(pixel[3]) * coverage) as u8); + } + } + draw::RasterImage::new([side; 2], pixels).ok() +} + +/// The account's picture, as the system shows it at sign-in, as a PNG at most `PICTURE` +/// pixels a side. +fn account_picture() -> &'static Option> { + static PNG: OnceLock>> = OnceLock::new(); + PNG.get_or_init(|| { + let image = draw::RasterImage::decode(&system_picture()?, [PICTURE; 2]).ok()?; + let [width, height] = image.size(); + let mut png = Vec::new(); + let mut encoder = png::Encoder::new(&mut png, width, height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + // Decoding premultiplies, which leaves an opaque photo as it was. + encoder + .write_header() + .ok()? + .write_image_data(image.pixels()) + .ok()?; + Some(png) + }) +} + +/// Directory Services keeps the picture as hex under `JPEGPhoto`, or names a file. +#[cfg(target_os = "macos")] +fn system_picture() -> Option> { + let user = format!("/Users/{}", std::env::var("USER").ok()?); + let read = |attribute: &str| { + let output = std::process::Command::new("/usr/bin/dscl") + .args([".", "-read", &user, attribute]) + .output() + .ok()?; + let text = String::from_utf8(output.stdout).ok()?; + Some(text.split_once(':')?.1.trim().to_owned()) + }; + if let Some(hex) = read("JPEGPhoto") { + let digits: Vec = hex.bytes().filter(u8::is_ascii_hexdigit).collect(); + let bytes: Option> = digits + .chunks(2) + .map(|pair| u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok()) + .collect(); + if let Some(bytes) = bytes.filter(|bytes| !bytes.is_empty()) { + return Some(bytes); + } + } + notebook::fs::read(read("Picture")?).ok() +} + +/// Where desktops keep it: `~/.face`, or AccountsService's icon for the account. +#[cfg(target_os = "linux")] +fn system_picture() -> Option> { + let home = std::path::PathBuf::from(std::env::var_os("HOME")?); + let user = std::env::var("USER").unwrap_or_default(); + [ + home.join(".face"), + home.join(".face.icon"), + std::path::Path::new("/var/lib/AccountsService/icons").join(user), + ] + .into_iter() + .find_map(|path| notebook::fs::read(path).ok()) +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +fn system_picture() -> Option> { + None +} diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 32c00b80d7f77944759e72647ae066dc7a09eca9..79d8287032e6335f1cec0fcdb7f20f5e8b486fcb 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -26,6 +26,8 @@ mod instance; mod keys; mod library; mod link; +#[cfg(feature = "live")] +mod live; #[cfg(target_os = "linux")] #[path = "loader_linux.rs"] mod loader; @@ -795,6 +797,8 @@ struct State { asking: std::collections::VecDeque, /// What has been read in each notebook, on this computer. reads: unread::Reads, + #[cfg(feature = "live")] + peers: Option, /// Open Notebook from Server while it is open. server: Option, /// New iCloud Notebook while it is open. @@ -1171,6 +1175,8 @@ impl State { #[cfg(target_os = "linux")] asking: Default::default(), reads, + #[cfg(feature = "live")] + peers: None, server: None, new_icloud: None, icloud_reading: HashSet::new(), @@ -1279,6 +1285,8 @@ impl State { let response = self.view.scale_factor_changed(scale)?; self.respond(response); } + #[cfg(feature = "live")] + self.follow_peers(); self.layout( [size.width, size.height].map(|side| side as f32 / self.window.scale_factor() as f32), scale, @@ -1629,6 +1637,8 @@ impl State { ..Spec::default() }, ); + #[cfg(feature = "live")] + self.avatars(); if self.session.is_some() { self.page_tools(&theme); } @@ -1750,6 +1760,10 @@ impl State { self.respond(response); } } + #[cfg(feature = "live")] + if !opening { + self.peer_carets(); + } self.ui.close(); if let Some(task) = self.view.task_under_pointer() { let [left, top, ..] = self.ui.rect(page()).unwrap_or_default(); @@ -2948,6 +2962,10 @@ impl State { } let found = self.search.found_in(&session.key()); let unread = self.unread_pages(); + #[cfg(feature = "live")] + let peers = self.peer_pages(); + #[cfg(not(feature = "live"))] + let peers = HashMap::new(); let rounding = self.rounding(); let dragged = self.dragged_page(); let rows = page_rows( @@ -2956,6 +2974,7 @@ impl State { section, session, [&found, &unread], + &peers, (rounding, self.page_tabs_left), self.renaming.as_mut(), dragged, @@ -4583,6 +4602,7 @@ fn page_rows( section: &ui::Section, session: &Session, marked: [&HashSet; 2], + peers: &HashMap>, shape: (f32, bool), mut renaming: Option<&mut rename::Renaming>, dragged: Option, @@ -4628,6 +4648,7 @@ fn page_rows( section, session, marked, + peers, shape, renaming.as_deref_mut(), (space, title, *level), @@ -4655,6 +4676,7 @@ fn page_rows( section, session, marked, + peers, shape, renaming, (space, title, *level), @@ -4676,6 +4698,7 @@ fn page_row( section: &ui::Section, session: &Session, [found, unread]: [&HashSet; 2], + peers: &HashMap>, shape: (f32, bool), renaming: Option<&mut rename::Renaming>, (space, title, level): (&ExGuid, &String, u32), @@ -4696,6 +4719,7 @@ fn page_row( conflicted: !versions.is_empty(), found: found.contains(space), unread: unread.contains(space), + peers: peers.get(space).map_or(&[], Vec::as_slice), renaming: renaming .filter(|renaming| renaming.page(*space)) .map(|renaming| &mut renaming.name), @@ -4756,6 +4780,7 @@ fn page_row( conflicted: false, found: false, unread: false, + peers: &[], renaming: None, shift, lifted: false, @@ -4788,6 +4813,8 @@ struct PageTab<'a> { found: bool, /// Another author changed it since it was last viewed, which OneNote sets bold. unread: bool, + /// The colours of the others who have it open. + peers: &'a [[f32; 4]], /// The name typed in the tab's rename field, while it shows one. renaming: Option<&'a mut String>, /// How far down from its place in the list it is drawn. @@ -4885,6 +4912,19 @@ fn page_tab( None } }; + for (index, color) in tab.peers.iter().enumerate() { + let inset = (ROW - ROW_GAP - 8.0) / 2.0; + ui.leaf( + ("peer", index), + Spec { + size: [px(10.0), px(ROW - ROW_GAP)], + fill: Some(*color), + radius: 4.0, + inset: [1.0, inset, 1.0, inset], + ..Spec::default() + }, + ); + } if tab.conflicted { ui.leaf( "conflict",