authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 02:19:06-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 02:58:06-07:00
logab00bdcf0c7a9eddb387eab4ad09df0b862c7b32
treedfe96636deee5ae2cb177578f9b2740c7722e1b2
parentf3bfb4de18ec03dca625dd95d5d5f2ae34383837
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: live presence between Snowbounds on one notebook (#35, feature `live`)

Peers find each other by mDNS (`_snowbound._tcp`) or connect directly, meet through SPAKE2 on a secret both hold (the notebook TOC identity, or a code typed on both), then exchange AES-256-GCM frames of a kind and a CBOR map that later versions extend by kinds and fields. Hello carries a name and the account picture; Presence carries section, page and caret as text object + UTF-16 offset. The app (only with SNOWBOUND_LIVE=network or loopback) draws avatars beside the search box, dots on page tabs and peers' carets, flags and selections; clicking an avatar opens their page. TextOutline::caret_at now holds the caret geometry the editor used inline. Design: resources/live-share.md. Assisted-by: claude-opus-5.5

13 files changed, 1495 insertions(+), 9 deletions(-)

AGENTS.md+1-1
......@@ -13,7 +13,7 @@ novel UI kit, each new platform port is extremely lightweight.
1313| Path | Owns | Depends on |
1414| --- | --- | --- |
1515| `crates/onestore` | The file format: revision stores (`.one`, `.onetoc2`), the page model, ops, the commit protocol. No network, no SQLite, no `unsafe`. | none |
16| `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 |
16| `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 |
1717| `crates/draw` | The wgpu renderer that page and chrome both paint through, and the text-editing core (keys, chords, carets) they share. | none |
1818| `crates/canvas` | The page: editor, OneNote-faithful layout, page scene, interaction, the page's accessibility tree. | onestore, draw |
1919| `crates/ui` | The immediate-mode interface kit and OneNote's chrome controls. Knows nothing of notebooks. | draw |
Cargo.lock+132
......@@ -908,6 +908,33 @@ version = "1.2.0"
908908source = "registry+https://github.com/rust-lang/crates.io-index"
909909checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
910910
911[[package]]
912name = "curve25519-dalek"
913version = "5.0.0"
914source = "registry+https://github.com/rust-lang/crates.io-index"
915checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23"
916dependencies = [
917 "cfg-if",
918 "cpufeatures",
919 "curve25519-dalek-derive",
920 "digest",
921 "fiat-crypto",
922 "rand_core 0.10.1",
923 "rustc_version",
924 "subtle",
925]
926
927[[package]]
928name = "curve25519-dalek-derive"
929version = "0.1.1"
930source = "registry+https://github.com/rust-lang/crates.io-index"
931checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
932dependencies = [
933 "proc-macro2",
934 "quote",
935 "syn 2.0.119",
936]
937
911938[[package]]
912939name = "data-url"
913940version = "0.3.2"
......@@ -1131,6 +1158,12 @@ dependencies = [
11311158 "simd-adler32",
11321159]
11331160
1161[[package]]
1162name = "fiat-crypto"
1163version = "0.3.0"
1164source = "registry+https://github.com/rust-lang/crates.io-index"
1165checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
1166
11341167[[package]]
11351168name = "file-guard"
11361169version = "0.2.0"
......@@ -1164,6 +1197,15 @@ version = "0.9.0"
11641197source = "registry+https://github.com/rust-lang/crates.io-index"
11651198checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
11661199
1200[[package]]
1201name = "flume"
1202version = "0.12.0"
1203source = "registry+https://github.com/rust-lang/crates.io-index"
1204checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be"
1205dependencies = [
1206 "spin",
1207]
1208
11671209[[package]]
11681210name = "foldhash"
11691211version = "0.2.0"
......@@ -1459,6 +1501,15 @@ version = "0.4.3"
14591501source = "registry+https://github.com/rust-lang/crates.io-index"
14601502checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
14611503
1504[[package]]
1505name = "hkdf"
1506version = "0.13.0"
1507source = "registry+https://github.com/rust-lang/crates.io-index"
1508checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018"
1509dependencies = [
1510 "hmac",
1511]
1512
14621513[[package]]
14631514name = "hmac"
14641515version = "0.13.0"
......@@ -1625,6 +1676,16 @@ version = "2.3.0"
16251676source = "registry+https://github.com/rust-lang/crates.io-index"
16261677checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad"
16271678
1679[[package]]
1680name = "if-addrs"
1681version = "0.15.0"
1682source = "registry+https://github.com/rust-lang/crates.io-index"
1683checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b"
1684dependencies = [
1685 "libc",
1686 "windows-sys 0.61.2",
1687]
1688
16281689[[package]]
16291690name = "image"
16301691version = "0.25.10"
......@@ -1913,6 +1974,20 @@ version = "0.8.1"
19131974source = "registry+https://github.com/rust-lang/crates.io-index"
19141975checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c"
19151976
1977[[package]]
1978name = "mdns-sd"
1979version = "0.21.4"
1980source = "registry+https://github.com/rust-lang/crates.io-index"
1981checksum = "1067efe2aadc6967f84c95977dca910e98f3edfd6403efc8fa8508d289ce012d"
1982dependencies = [
1983 "fastrand",
1984 "flume",
1985 "if-addrs",
1986 "mio",
1987 "socket-pktinfo",
1988 "socket2",
1989]
1990
19161991[[package]]
19171992name = "memchr"
19181993version = "2.8.3"
......@@ -1937,6 +2012,26 @@ dependencies = [
19372012 "autocfg",
19382013]
19392014
2015[[package]]
2016name = "minicbor"
2017version = "2.3.0"
2018source = "registry+https://github.com/rust-lang/crates.io-index"
2019checksum = "c12b4033ffaa92fbf9df03df38d19324f52bad130dd223f811734a8006dd2d69"
2020dependencies = [
2021 "minicbor-derive",
2022]
2023
2024[[package]]
2025name = "minicbor-derive"
2026version = "0.19.5"
2027source = "registry+https://github.com/rust-lang/crates.io-index"
2028checksum = "84f5ad8dfe10176465fe33f19ecfcf08783ba66630fdb40b2e4542547c1046f0"
2029dependencies = [
2030 "proc-macro2",
2031 "quote",
2032 "syn 2.0.119",
2033]
2034
19402035[[package]]
19412036name = "miniz_oxide"
19422037version = "0.8.9"
......@@ -1964,6 +2059,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
19642059checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
19652060dependencies = [
19662061 "libc",
2062 "log",
19672063 "wasi",
19682064 "windows-sys 0.61.2",
19692065]
......@@ -2089,6 +2185,8 @@ dependencies = [
20892185 "hmac",
20902186 "js-sys",
20912187 "libc",
2188 "mdns-sd",
2189 "minicbor",
20922190 "nix",
20932191 "onestore",
20942192 "rsqlite-vfs",
......@@ -2097,6 +2195,7 @@ dependencies = [
20972195 "serde_json",
20982196 "sha2",
20992197 "smb2",
2198 "spake2",
21002199 "tempfile",
21012200 "thiserror 2.0.20",
21022201 "tokio",
......@@ -3602,6 +3701,17 @@ dependencies = [
36023701 "zeroize",
36033702]
36043703
3704[[package]]
3705name = "socket-pktinfo"
3706version = "0.4.1"
3707source = "registry+https://github.com/rust-lang/crates.io-index"
3708checksum = "612942246d0cc239cfd83af1dfd39be47f649208a3524e5e9da651910128e0ac"
3709dependencies = [
3710 "libc",
3711 "socket2",
3712 "windows-sys 0.61.2",
3713]
3714
36053715[[package]]
36063716name = "socket2"
36073717version = "0.6.5"
......@@ -3612,6 +3722,19 @@ dependencies = [
36123722 "windows-sys 0.61.2",
36133723]
36143724
3725[[package]]
3726name = "spake2"
3727version = "0.5.0-pre.0"
3728source = "registry+https://github.com/rust-lang/crates.io-index"
3729checksum = "7d5601a88f45d069ad786f75c4fb13e7f127b0aadda913fb2cd65948d3c9a561"
3730dependencies = [
3731 "curve25519-dalek",
3732 "getrandom 0.4.3",
3733 "hkdf",
3734 "rand_core 0.10.1",
3735 "sha2",
3736]
3737
36153738[[package]]
36163739name = "spellbook"
36173740version = "0.4.2"
......@@ -3622,6 +3745,15 @@ dependencies = [
36223745 "hashbrown 0.17.1",
36233746]
36243747
3748[[package]]
3749name = "spin"
3750version = "0.9.9"
3751source = "registry+https://github.com/rust-lang/crates.io-index"
3752checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
3753dependencies = [
3754 "lock_api",
3755]
3756
36253757[[package]]
36263758name = "spirv"
36273759version = "0.4.0+sdk-1.4.341.0"
crates/canvas/src/editor.rs+18-8
......@@ -529,6 +529,23 @@ impl TextOutline {
529529 .into()
530530 }
531531
532 /// The caret at `focus`, in the outline's coordinates.
533 pub fn caret_at(
534 &self,
535 focus: TextPosition,
536 affinity: Affinity,
537 width: f32,
538 ) -> Result<BoundingBox, EditError> {
539 let paragraph = self.paragraph_layout(focus.paragraph)?;
540 let cursor = paragraph.cursor(focus.offset, affinity)?;
541 let mut rect = paragraph.text.caret(cursor, width);
542 rect.x0 += f64::from(paragraph.origin[0]);
543 rect.x1 += f64::from(paragraph.origin[0]);
544 rect.y0 += f64::from(paragraph.origin[1]);
545 rect.y1 += f64::from(paragraph.origin[1]);
546 Ok(rect)
547 }
548
532549 /// Highlight rectangles of `selection` in the outline, in its coordinates.
533550 pub fn range_rects(&self, selection: Selection) -> Result<Vec<BoundingBox>, EditError> {
534551 let [anchor, focus] = selection.positions;
......@@ -2989,14 +3006,7 @@ impl CanvasEditor {
29893006 affinity: Affinity,
29903007 width: f32,
29913008 ) -> Result<BoundingBox, EditError> {
2992 let paragraph = self.active_outline().paragraph_layout(focus.paragraph)?;
2993 let cursor = paragraph.cursor(focus.offset, affinity)?;
2994 let mut rect = paragraph.text.caret(cursor, width);
2995 rect.x0 += f64::from(paragraph.origin[0]);
2996 rect.x1 += f64::from(paragraph.origin[0]);
2997 rect.y0 += f64::from(paragraph.origin[1]);
2998 rect.y1 += f64::from(paragraph.origin[1]);
2999 Ok(rect)
3009 self.active_outline().caret_at(focus, affinity, width)
30003010 }
30013011
30023012 pub fn move_selection(
crates/notebook/Cargo.toml+6
......@@ -6,6 +6,9 @@ publish = false
66
77[features]
88smb = ["dep:smb2", "dep:tokio"]
9# Live presence: peers found on the network, met through a shared secret, over an encrypted
10# stream (resources/live-share.md).
11live = ["dep:mdns-sd", "dep:minicbor", "dep:spake2"]
912
1013[dependencies]
1114onestore = { path = "../onestore" }
......@@ -23,6 +26,9 @@ aes-gcm = "0.11.1"
2326base64 = { version = "0.23.1", default-features = false, features = ["std"] }
2427getrandom = "0.4.3"
2528hmac = "0.13.0"
29mdns-sd = { version = "0.21.4", default-features = false, optional = true }
30minicbor = { version = "2.3.0", features = ["derive", "alloc"], optional = true }
31spake2 = { version = "=0.5.0-pre.0", features = ["getrandom"], optional = true }
2632# OneNote packages (.onepkg) are cabinet files.
2733cab = "0.6"
2834# std's clock where there is one; the browser's on wasm32-unknown-unknown, whose std has none.
crates/notebook/README.md+13
......@@ -529,6 +529,19 @@ Device and simulator builds link for iOS. Native acceptance uses disposable
529529OneNote 2010 clients and Samba; it does not establish on-device execution or
530530physical power-loss durability.
531531
532## Live presence (feature `live`)
533
534`live::Live::start(hello, room, reach, notify)` listens on a TCP port and, with a `Reach`,
535advertises `_snowbound._tcp` by mDNS on every network or on loopback alone, connecting to the
536peers in the same `Room` that it finds: a notebook's identity, or a code typed on both
537(`Room::Code("4-violet-otter")`). `connect(address)` meets a peer discovery did not find.
538Peers meet through SPAKE2 on the room's secret, then every frame is AES-256-GCM under the keys
539it agreed: a message kind and a CBOR map (`live::wire`). A reader skips kinds and map keys it
540doesn't know, so later versions add both freely. `set_presence` says which section, page and
541caret this end has (text object and UTF-16 offset, as ops address text); a connection sends
542only the newest. `peers()` lists each connected peer's `Hello` (name, picture) and presence,
543and `notify` runs whenever that changes. Dropping the `Live` leaves.
544
532545## Queue measurement
533546
534547`cargo run -p notebook --release --example queue_scale -- NEW_DIRECTORY 1000`
crates/notebook/src/lib.rs+2
......@@ -4,6 +4,8 @@
44
55pub mod discover;
66pub mod fs;
7#[cfg(feature = "live")]
8pub mod live;
79#[cfg(feature = "smb")]
810pub mod smb;
911
crates/notebook/src/live.rs created+394
......@@ -0,0 +1,394 @@
1//! Live presence: who else has the notebook open, the page they are on and their caret,
2//! straight from one Snowbound to another. Peers find each other with mDNS
3//! (`_snowbound._tcp`) and meet through a secret both hold, a notebook's identity or a code
4//! typed on both, which SPAKE2 turns into the keys every frame after the opening is sealed
5//! with. The lower peer id connects; each connection has a thread reading and one writing.
6
7pub mod wire;
8pub use wire::{Caret, Guid, Hello, Presence, Spot};
9
10use mdns_sd::{IfKind, ServiceDaemon, ServiceEvent, ServiceInfo};
11use sha2::{Digest, Sha256};
12use std::{
13 collections::BTreeMap,
14 io,
15 net::{IpAddr, Ipv4Addr, Shutdown, SocketAddr, TcpListener, TcpStream},
16 sync::{
17 Arc, Mutex,
18 atomic::{AtomicBool, AtomicU64, Ordering},
19 mpsc,
20 },
21 thread,
22 time::Duration,
23};
24use wire::{Sealer, Side, kind};
25
26const SERVICE: &str = "_snowbound._tcp.local.";
27/// How long a connection may be quiet before a ping, and before the peer counts as gone.
28const PING: Duration = Duration::from_secs(15);
29const GONE: Duration = Duration::from_secs(45);
30const OPENING: Duration = Duration::from_secs(5);
31
32/// The secret peers meet through.
33#[derive(Clone, Debug, PartialEq, Eq)]
34pub enum Room {
35 /// Everyone with the notebook: its table of contents' file identity, which only its files
36 /// hold.
37 Notebook([u8; 16]),
38 /// A code typed on both: `7-violet-otter`, whose number names it on the network and whose
39 /// words only the two people know.
40 Code(String),
41}
42
43impl Room {
44 /// What names the room in the clear: a hash of a notebook's identity, a code's number.
45 fn tag(&self) -> String {
46 match self {
47 Room::Notebook(id) => hex(&Sha256::digest([&b"Snowbound room "[..], id].concat())[..8]),
48 Room::Code(code) => format!("code-{}", code.split('-').next().unwrap_or_default()),
49 }
50 }
51
52 fn secret(&self) -> Vec<u8> {
53 match self {
54 Room::Notebook(id) => id.to_vec(),
55 Room::Code(code) => code.trim().to_lowercase().into_bytes(),
56 }
57 }
58}
59
60/// Where peers are looked for.
61#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62pub enum Reach {
63 /// Every network this computer is on.
64 Network,
65 /// This computer alone, for two copies of the app side by side.
66 Loopback,
67}
68
69#[derive(Clone, Debug)]
70pub struct Peer {
71 pub hello: Arc<Hello>,
72 /// None until the peer first says where it is.
73 pub presence: Option<Presence>,
74}
75
76/// Presence on the network while it lives; dropping it leaves.
77pub struct Live {
78 shared: Arc<Shared>,
79 address: SocketAddr,
80 daemon: Option<ServiceDaemon>,
81}
82
83struct Shared {
84 me: Hello,
85 tag: String,
86 secret: Vec<u8>,
87 state: Mutex<State>,
88 notify: Box<dyn Fn() + Send + Sync>,
89 stopped: AtomicBool,
90 connections: AtomicU64,
91}
92
93#[derive(Default)]
94struct State {
95 presence: Presence,
96 /// Counts changes to `presence`, so a writer sends only the newest.
97 generation: u64,
98 peers: BTreeMap<[u8; 16], Link>,
99}
100
101struct Link {
102 connection: u64,
103 peer: Peer,
104 wake: mpsc::Sender<()>,
105 stream: TcpStream,
106}
107
108impl Live {
109 /// Starts listening as `me` in `room`, advertised and looked for where `reach` says, or
110 /// not at all with `None`, leaving peers to `connect`. `notify` runs on a network thread
111 /// whenever `peers` changes.
112 pub fn start(
113 me: Hello,
114 room: &Room,
115 reach: Option<Reach>,
116 notify: impl Fn() + Send + Sync + 'static,
117 ) -> io::Result<Live> {
118 let host = match reach {
119 Some(Reach::Network) => IpAddr::V4(Ipv4Addr::UNSPECIFIED),
120 Some(Reach::Loopback) | None => IpAddr::V4(Ipv4Addr::LOCALHOST),
121 };
122 let listener = TcpListener::bind((host, 0))?;
123 let mut address = listener.local_addr()?;
124 if address.ip().is_unspecified() {
125 address.set_ip(IpAddr::V4(Ipv4Addr::LOCALHOST));
126 }
127 let shared = Arc::new(Shared {
128 me,
129 tag: room.tag(),
130 secret: room.secret(),
131 state: Mutex::default(),
132 notify: Box::new(notify),
133 stopped: AtomicBool::new(false),
134 connections: AtomicU64::new(0),
135 });
136 let accepting = Arc::clone(&shared);
137 thread::Builder::new()
138 .name("live accept".into())
139 .spawn(move || {
140 for stream in listener.incoming() {
141 if accepting.stopped.load(Ordering::Acquire) {
142 return;
143 }
144 if let Ok(stream) = stream {
145 let shared = Arc::clone(&accepting);
146 thread::spawn(move || shared.run(stream, Side::Responder));
147 }
148 }
149 })?;
150 let daemon = match reach {
151 Some(reach) => {
152 Some(advertise(&shared, reach, address.port()).map_err(io::Error::other)?)
153 }
154 None => None,
155 };
156 Ok(Live {
157 shared,
158 address,
159 daemon,
160 })
161 }
162
163 /// Where this end listens.
164 pub fn address(&self) -> SocketAddr {
165 self.address
166 }
167
168 /// Connects to a peer at `address` that discovery did not find.
169 pub fn connect(&self, address: SocketAddr) {
170 let shared = Arc::clone(&self.shared);
171 thread::spawn(move || shared.dial(address));
172 }
173
174 /// Says where this end is now; peers hear only the newest of quick changes.
175 pub fn set_presence(&self, presence: Presence) {
176 let mut state = self.shared.state.lock().unwrap();
177 if state.presence == presence {
178 return;
179 }
180 state.presence = presence;
181 state.generation += 1;
182 for link in state.peers.values() {
183 let _ = link.wake.send(());
184 }
185 }
186
187 /// The peers connected now, by id.
188 pub fn peers(&self) -> Vec<Peer> {
189 let state = self.shared.state.lock().unwrap();
190 state.peers.values().map(|link| link.peer.clone()).collect()
191 }
192}
193
194impl Drop for Live {
195 fn drop(&mut self) {
196 self.shared.stopped.store(true, Ordering::Release);
197 if let Some(daemon) = &self.daemon {
198 let _ = daemon.shutdown();
199 }
200 // Wakes the accepting thread to see it has stopped.
201 let _ = TcpStream::connect_timeout(&self.address, OPENING);
202 for link in self.shared.state.lock().unwrap().peers.values() {
203 let _ = link.stream.shutdown(Shutdown::Both);
204 }
205 }
206}
207
208/// Advertises `shared` on `port` and connects to the peers in its room that discovery finds
209/// with a higher id than its own, which leave the connecting to it.
210fn advertise(shared: &Arc<Shared>, reach: Reach, port: u16) -> mdns_sd::Result<ServiceDaemon> {
211 let daemon = ServiceDaemon::new()?;
212 let id = hex(&shared.me.peer);
213 let properties = [
214 ("v", "1"),
215 ("room", shared.tag.as_str()),
216 ("peer", id.as_str()),
217 ];
218 let host = format!("snowbound-{id}.local.");
219 let info = match reach {
220 Reach::Network => {
221 ServiceInfo::new(SERVICE, &id, &host, "", port, &properties[..])?.enable_addr_auto()
222 }
223 Reach::Loopback => {
224 daemon.disable_interface(IfKind::All)?;
225 daemon.enable_interface(IfKind::LoopbackV4)?;
226 ServiceInfo::new(
227 SERVICE,
228 &id,
229 &host,
230 IpAddr::V4(Ipv4Addr::LOCALHOST),
231 port,
232 &properties[..],
233 )?
234 }
235 };
236 daemon.register(info)?;
237 let found = daemon.browse(SERVICE)?;
238 let shared = Arc::clone(shared);
239 thread::Builder::new()
240 .name("live discovery".into())
241 .spawn(move || {
242 while let Ok(event) = found.recv() {
243 let ServiceEvent::ServiceResolved(service) = event else {
244 continue;
245 };
246 let (Some(room), Some(peer)) = (
247 service.get_property_val_str("room"),
248 service.get_property_val_str("peer"),
249 ) else {
250 continue;
251 };
252 if room != shared.tag || peer <= id.as_str() || shared.knows(peer) {
253 continue;
254 }
255 let mut addresses: Vec<IpAddr> =
256 service.addresses.iter().map(|ip| ip.to_ip_addr()).collect();
257 addresses.sort_by_key(|ip| (!ip.is_ipv4(), !ip.is_loopback()));
258 if let Some(ip) = addresses.first() {
259 let address = SocketAddr::new(*ip, service.port);
260 let shared = Arc::clone(&shared);
261 thread::spawn(move || shared.dial(address));
262 }
263 }
264 })
265 .map_err(|error| mdns_sd::Error::Msg(error.to_string()))?;
266 Ok(daemon)
267}
268
269impl Shared {
270 fn knows(&self, peer: &str) -> bool {
271 let state = self.state.lock().unwrap();
272 state.peers.keys().any(|id| hex(id) == peer)
273 }
274
275 fn dial(self: Arc<Self>, address: SocketAddr) {
276 if let Ok(stream) = TcpStream::connect_timeout(&address, OPENING) {
277 self.run(stream, Side::Initiator);
278 }
279 }
280
281 /// Meets the peer at the other end of `stream`, then reads from it until it goes.
282 fn run(self: Arc<Self>, mut stream: TcpStream, side: Side) {
283 if self.stopped.load(Ordering::Acquire) {
284 return;
285 }
286 let met = (|| {
287 stream.set_read_timeout(Some(OPENING))?;
288 stream.set_nodelay(true)?;
289 let (mut send, mut receive) = wire::open(&mut stream, side, &self.tag, &self.secret)?;
290 send.send(&mut stream, kind::HELLO, &self.me)?;
291 let (first, body) = receive.receive(&mut stream)?;
292 if first != kind::HELLO {
293 return Err(io::Error::new(io::ErrorKind::InvalidData, "No hello"));
294 }
295 let hello: Hello = minicbor::decode(&body)
296 .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "A malformed hello"))?;
297 stream.set_read_timeout(Some(GONE))?;
298 Ok((send, receive, hello))
299 })();
300 let (send, mut receive, hello) = match met {
301 Ok(met) => met,
302 Err(error) => {
303 eprintln!("Live: no meeting with {:?}: {error}", stream.peer_addr());
304 return;
305 }
306 };
307 let peer = hello.peer;
308 let connection = self.connections.fetch_add(1, Ordering::Relaxed);
309 let (wake, woken) = mpsc::channel();
310 {
311 let mut state = self.state.lock().unwrap();
312 if peer == self.me.peer || state.peers.contains_key(&peer) {
313 return;
314 }
315 let Ok(writing) = stream.try_clone() else {
316 return;
317 };
318 let _ = wake.send(());
319 state.peers.insert(
320 peer,
321 Link {
322 connection,
323 peer: Peer {
324 hello: Arc::new(hello),
325 presence: None,
326 },
327 wake,
328 stream: writing,
329 },
330 );
331 }
332 (self.notify)();
333 if let Ok(writing) = stream.try_clone() {
334 let shared = Arc::clone(&self);
335 thread::spawn(move || shared.write(writing, send, woken));
336 }
337 while let Ok((message, body)) = receive.receive(&mut stream) {
338 if message != kind::PRESENCE {
339 continue;
340 }
341 let Ok(presence) = minicbor::decode::<Presence>(&body) else {
342 break;
343 };
344 if let Some(link) = self.state.lock().unwrap().peers.get_mut(&peer) {
345 link.peer.presence = Some(presence);
346 }
347 (self.notify)();
348 }
349 let _ = stream.shutdown(Shutdown::Both);
350 let mut state = self.state.lock().unwrap();
351 if state
352 .peers
353 .get(&peer)
354 .is_some_and(|link| link.connection == connection)
355 {
356 state.peers.remove(&peer);
357 drop(state);
358 (self.notify)();
359 }
360 }
361
362 /// Sends the newest presence whenever woken, and a ping when quiet.
363 fn write(&self, mut stream: TcpStream, mut send: Sealer, woken: mpsc::Receiver<()>) {
364 let mut sent = None;
365 loop {
366 let result = match woken.recv_timeout(PING) {
367 Ok(()) => {
368 let presence = {
369 let state = self.state.lock().unwrap();
370 if sent == Some(state.generation) {
371 continue;
372 }
373 sent = Some(state.generation);
374 state.presence.clone()
375 };
376 send.send(&mut stream, kind::PRESENCE, &presence)
377 }
378 Err(mpsc::RecvTimeoutError::Timeout) => send.send(&mut stream, kind::PING, &()),
379 Err(mpsc::RecvTimeoutError::Disconnected) => return,
380 };
381 if result.is_err() {
382 let _ = stream.shutdown(Shutdown::Both);
383 return;
384 }
385 }
386 }
387}
388
389fn hex(bytes: &[u8]) -> String {
390 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
391}
392
393#[cfg(test)]
394mod tests;
crates/notebook/src/live/tests.rs created+146
......@@ -0,0 +1,146 @@
1use super::*;
2use std::time::Instant;
3
4fn hello(name: &str) -> Hello {
5 Hello::new(name.into(), Some(vec![1, 2, 3])).unwrap()
6}
7
8/// Waits until `done` holds of `live`'s peers.
9fn until(live: &Live, done: impl Fn(&[Peer]) -> bool) -> Vec<Peer> {
10 let deadline = Instant::now() + Duration::from_secs(10);
11 loop {
12 let peers = live.peers();
13 if done(&peers) {
14 return peers;
15 }
16 assert!(Instant::now() < deadline, "peers stayed {peers:?}");
17 thread::sleep(Duration::from_millis(20));
18 }
19}
20
21fn caret(offset: u32) -> Presence {
22 let spot = Spot {
23 text: Guid {
24 guid: [7; 16],
25 n: 3,
26 },
27 offset,
28 };
29 Presence {
30 section: Some([5; 16]),
31 page: Some(Guid {
32 guid: [6; 16],
33 n: 1,
34 }),
35 caret: Some(Caret {
36 anchor: spot,
37 focus: spot,
38 }),
39 }
40}
41
42/// Two ends of one code meet, greet each other by name and picture, hear each other's newest
43/// caret, and see the other leave.
44#[test]
45fn peers_meet_and_follow_presence() {
46 let room = Room::Code("7-violet-otter".into());
47 let ada = Live::start(hello("Ada"), &room, None, || {}).unwrap();
48 let grace = Live::start(hello("Grace"), &room, None, || {}).unwrap();
49 ada.set_presence(caret(1));
50 ada.connect(grace.address());
51 let seen = until(&grace, |peers| {
52 peers.iter().any(|peer| peer.presence.is_some())
53 });
54 assert_eq!(seen[0].hello.name, "Ada");
55 assert_eq!(seen[0].hello.picture.as_deref(), Some(&[1, 2, 3][..]));
56 assert_eq!(seen[0].presence, Some(caret(1)));
57 until(&ada, |peers| {
58 peers.len() == 1 && peers[0].hello.name == "Grace"
59 });
60 for offset in 2..20 {
61 ada.set_presence(caret(offset));
62 }
63 until(&grace, |peers| peers[0].presence == Some(caret(19)));
64 drop(ada);
65 until(&grace, <[Peer]>::is_empty);
66}
67
68/// A peer holding another code never meets: its first frame does not open.
69#[test]
70fn another_code_never_meets() {
71 let ada = Live::start(
72 hello("Ada"),
73 &Room::Code("7-violet-otter".into()),
74 None,
75 || {},
76 )
77 .unwrap();
78 let mallory = Live::start(
79 hello("Mallory"),
80 &Room::Code("7-violet-ocelot".into()),
81 None,
82 || {},
83 )
84 .unwrap();
85 mallory.connect(ada.address());
86 thread::sleep(Duration::from_millis(500));
87 assert!(ada.peers().is_empty() && mallory.peers().is_empty());
88}
89
90/// A field or a kind a later version adds is skipped by this one.
91#[test]
92fn later_fields_and_kinds_are_skipped() {
93 #[derive(Encode)]
94 #[cbor(map)]
95 struct Later {
96 #[cbor(n(0), with = "minicbor::bytes")]
97 section: Option<[u8; 16]>,
98 #[n(9)]
99 mood: String,
100 }
101 use minicbor::Encode;
102 let body = minicbor::to_vec(Later {
103 section: Some([5; 16]),
104 mood: "curious".into(),
105 })
106 .unwrap();
107 let presence: Presence = minicbor::decode(&body).unwrap();
108 assert_eq!(
109 presence,
110 Presence {
111 section: Some([5; 16]),
112 ..Presence::default()
113 }
114 );
115
116 let room = Room::Code("4-quiet-heron".into());
117 let grace = Live::start(hello("Grace"), &room, None, || {}).unwrap();
118 // A later version: it greets, says something new, then where it is.
119 let later = thread::spawn(move || {
120 let mut stream = TcpStream::connect(grace.address()).unwrap();
121 let (mut send, mut receive) =
122 wire::open(&mut stream, Side::Initiator, &room.tag(), &room.secret()).unwrap();
123 send.send(&mut stream, kind::HELLO, &hello("Later"))
124 .unwrap();
125 receive.receive(&mut stream).unwrap();
126 send.send(&mut stream, 999, &"a chat message").unwrap();
127 send.send(&mut stream, kind::PRESENCE, &caret(4)).unwrap();
128 until(&grace, |peers| {
129 peers
130 .first()
131 .is_some_and(|peer| peer.presence == Some(caret(4)))
132 });
133 });
134 later.join().unwrap();
135}
136
137/// Two ends of one notebook find each other by mDNS on this computer's loopback.
138#[test]
139#[ignore = "multicasts mDNS on the loopback interface"]
140fn peers_find_each_other_on_loopback() {
141 let room = Room::Notebook([9; 16]);
142 let ada = Live::start(hello("Ada"), &room, Some(Reach::Loopback), || {}).unwrap();
143 let grace = Live::start(hello("Grace"), &room, Some(Reach::Loopback), || {}).unwrap();
144 until(&ada, |peers| peers.len() == 1);
145 until(&grace, |peers| peers.len() == 1);
146}
crates/notebook/src/live/wire.rs created+267
......@@ -0,0 +1,267 @@
1//! What peers say to each other: an opening in the clear that meets through the secret both
2//! hold (SPAKE2), then frames sealed under the keys it agreed, each a message kind and a CBOR
3//! map. A later version adds kinds and fields; a reader skips the kinds and fields it doesn't
4//! know, so every version speaks to every other.
5
6use aes_gcm::{Aes256Gcm, KeyInit, aead::Aead};
7use hmac::{Hmac, Mac};
8use minicbor::{Decode, Encode};
9use sha2::Sha256;
10use spake2::{Ed25519Group, Identity, Password, Spake2};
11use std::io::{self, Read, Write};
12
13/// The opening's version. Frames after it never change shape; they grow by kinds and fields.
14pub const VERSION: u16 = 1;
15/// The largest block either side reads.
16const MOST: usize = 16 << 20;
17
18/// Message kinds.
19pub mod kind {
20 pub const HELLO: u16 = 1;
21 /// Keeps a quiet connection open; it says nothing else.
22 pub const PING: u16 = 2;
23 pub const PRESENCE: u16 = 16;
24}
25
26/// The kinds this version reads, as `Hello::kinds` lists them.
27pub const KNOWN: &[u16] = &[kind::HELLO, kind::PING, kind::PRESENCE];
28
29/// The first message each way, and the only one with a name and a picture.
30#[derive(Clone, Debug, PartialEq, Encode, Decode)]
31#[cbor(map)]
32pub struct Hello {
33 /// Random for each run of the app.
34 #[cbor(n(0), with = "minicbor::bytes")]
35 pub peer: [u8; 16],
36 #[n(1)]
37 pub name: String,
38 /// A PNG of the person, at most 96 pixels a side.
39 #[cbor(n(2), with = "minicbor::bytes")]
40 pub picture: Option<Vec<u8>>,
41 /// The app and its version, for diagnostics.
42 #[n(3)]
43 pub app: String,
44 /// The message kinds the sender reads; a request waits for its kind to be listed.
45 #[n(4)]
46 pub kinds: Vec<u16>,
47}
48
49impl Hello {
50 /// A hello from a new peer, named `name`, reading the kinds this version reads.
51 pub fn new(name: String, picture: Option<Vec<u8>>) -> io::Result<Self> {
52 let mut peer = [0; 16];
53 getrandom::fill(&mut peer).map_err(|_| io::Error::other("System random source failed"))?;
54 Ok(Self {
55 peer,
56 name,
57 picture,
58 app: format!("Snowbound {}", env!("CARGO_PKG_VERSION")),
59 kinds: KNOWN.to_vec(),
60 })
61 }
62}
63
64/// Where someone is: the section and page they have open and their caret on it.
65#[derive(Clone, Debug, Default, PartialEq, Encode, Decode)]
66#[cbor(map)]
67pub struct Presence {
68 /// The section file's identity (its header's guidFile).
69 #[cbor(n(0), with = "minicbor::bytes")]
70 pub section: Option<[u8; 16]>,
71 /// The page's object space.
72 #[n(1)]
73 pub page: Option<Guid>,
74 #[n(2)]
75 pub caret: Option<Caret>,
76}
77
78/// A selection, collapsed where `anchor` is `focus`.
79#[derive(Clone, Copy, Debug, PartialEq, Encode, Decode)]
80#[cbor(map)]
81pub struct Caret {
82 #[n(0)]
83 pub anchor: Spot,
84 #[n(1)]
85 pub focus: Spot,
86}
87
88/// A place in a text object, in UTF-16 code units, as ops address text.
89#[derive(Clone, Copy, Debug, PartialEq, Encode, Decode)]
90#[cbor(map)]
91pub struct Spot {
92 #[n(0)]
93 pub text: Guid,
94 #[n(1)]
95 pub offset: u32,
96}
97
98/// An `ExGuid`.
99#[derive(Clone, Copy, Debug, PartialEq, Eq, Encode, Decode)]
100#[cbor(array)]
101pub struct Guid {
102 #[cbor(n(0), with = "minicbor::bytes")]
103 pub guid: [u8; 16],
104 #[n(1)]
105 pub n: u32,
106}
107
108impl From<onestore::ExGuid> for Guid {
109 fn from(id: onestore::ExGuid) -> Self {
110 Self {
111 guid: id.guid,
112 n: id.n,
113 }
114 }
115}
116
117impl From<Guid> for onestore::ExGuid {
118 fn from(id: Guid) -> Self {
119 Self {
120 guid: id.guid,
121 n: id.n,
122 }
123 }
124}
125
126/// The opening, sent in the clear by the side that connected and answered by the other.
127#[derive(Encode, Decode)]
128#[cbor(map)]
129struct Open {
130 #[n(0)]
131 version: u16,
132 /// The room it means, as discovery names it.
133 #[n(1)]
134 room: String,
135 #[cbor(n(2), with = "minicbor::bytes")]
136 pake: Vec<u8>,
137}
138
139/// One direction's AEAD key and the count of frames sealed under it, which is each frame's nonce.
140pub struct Sealer {
141 cipher: Aes256Gcm,
142 count: u64,
143}
144
145impl Sealer {
146 fn new(key: &[u8], purpose: &[u8]) -> Self {
147 let mut mac = <Hmac<Sha256> as hmac::KeyInit>::new_from_slice(key).expect("any key length");
148 mac.update(purpose);
149 Self {
150 cipher: Aes256Gcm::new_from_slice(&mac.finalize().into_bytes()).expect("a 32-byte key"),
151 count: 0,
152 }
153 }
154
155 fn nonce(&mut self) -> [u8; 12] {
156 let mut nonce = [0; 12];
157 nonce[4..].copy_from_slice(&self.count.to_be_bytes());
158 self.count += 1;
159 nonce
160 }
161
162 /// Writes message `kind` holding `body`.
163 pub fn send(
164 &mut self,
165 to: &mut impl Write,
166 kind: u16,
167 body: &impl Encode<()>,
168 ) -> io::Result<()> {
169 let mut clear = kind.to_be_bytes().to_vec();
170 minicbor::encode(body, &mut clear).map_err(io::Error::other)?;
171 let nonce = self.nonce();
172 let sealed = self
173 .cipher
174 .encrypt(&nonce.into(), clear.as_slice())
175 .map_err(|_| io::Error::other("A frame could not be sealed"))?;
176 write_block(to, &sealed)
177 }
178
179 /// Reads the next message: its kind and body.
180 pub fn receive(&mut self, from: &mut impl Read) -> io::Result<(u16, Vec<u8>)> {
181 let sealed = read_block(from)?;
182 let nonce = self.nonce();
183 let mut clear = self
184 .cipher
185 .decrypt(&nonce.into(), sealed.as_slice())
186 .map_err(|_| invalid("A frame does not open under the agreed key"))?;
187 let body = clear.split_off(2.min(clear.len()));
188 let kind = u16::from_be_bytes(clear.try_into().map_err(|_| invalid("An empty frame"))?);
189 Ok((kind, body))
190 }
191}
192
193/// Which end of the connection this is.
194#[derive(Clone, Copy, PartialEq, Eq)]
195pub enum Side {
196 Initiator,
197 Responder,
198}
199
200/// Meets the peer at the other end of `stream` in `room` through `secret`: the sealers to
201/// send and to receive with. A peer holding another secret goes unnoticed here; its first
202/// frame then fails to open.
203pub fn open(
204 stream: &mut (impl Read + Write),
205 side: Side,
206 room: &str,
207 secret: &[u8],
208) -> io::Result<(Sealer, Sealer)> {
209 let password = Password::new(secret);
210 let [initiator, responder] = [b"initiator", b"responder"]
211 .map(|role| Identity::new(&[&role[..], room.as_bytes()].concat()));
212 let (pake, message) = match side {
213 Side::Initiator => Spake2::<Ed25519Group>::start_a(&password, &initiator, &responder),
214 Side::Responder => Spake2::<Ed25519Group>::start_b(&password, &initiator, &responder),
215 };
216 let ours = Open {
217 version: VERSION,
218 room: room.into(),
219 pake: message,
220 };
221 if side == Side::Initiator {
222 write_block(stream, &minicbor::to_vec(&ours).map_err(io::Error::other)?)?;
223 }
224 let theirs: Open =
225 minicbor::decode(&read_block(stream)?).map_err(|_| invalid("A malformed opening"))?;
226 if theirs.version != VERSION || theirs.room != room {
227 return Err(invalid("The peer means another room or version"));
228 }
229 if side == Side::Responder {
230 write_block(stream, &minicbor::to_vec(&ours).map_err(io::Error::other)?)?;
231 }
232 let key = pake
233 .finish(&theirs.pake)
234 .map_err(|_| invalid("A malformed key exchange"))?;
235 let [from_initiator, from_responder] = [
236 Sealer::new(&key, b"Snowbound live v1 initiator"),
237 Sealer::new(&key, b"Snowbound live v1 responder"),
238 ];
239 Ok(match side {
240 Side::Initiator => (from_initiator, from_responder),
241 Side::Responder => (from_responder, from_initiator),
242 })
243}
244
245fn write_block(to: &mut impl Write, bytes: &[u8]) -> io::Result<()> {
246 let length = u32::try_from(bytes.len())
247 .ok()
248 .filter(|length| *length as usize <= MOST)
249 .ok_or_else(|| invalid("A frame too large to send"))?;
250 to.write_all(&[&length.to_be_bytes()[..], bytes].concat())
251}
252
253fn read_block(from: &mut impl Read) -> io::Result<Vec<u8>> {
254 let mut length = [0; 4];
255 from.read_exact(&mut length)?;
256 let length = u32::from_be_bytes(length) as usize;
257 if length > MOST {
258 return Err(invalid("A frame too large to read"));
259 }
260 let mut bytes = vec![0; length];
261 from.read_exact(&mut bytes)?;
262 Ok(bytes)
263}
264
265fn invalid(message: &'static str) -> io::Error {
266 io::Error::new(io::ErrorKind::InvalidData, message)
267}
crates/snowbound/Cargo.toml+3
......@@ -8,6 +8,9 @@ publish = false
88default = ["wgpu"]
99# Paints through wgpu; without it through OpenGL 2.1, for Mac OS X 10.6.
1010wgpu = ["draw/wgpu", "dep:wgpu"]
11# Live presence: the others with a notebook open, their pages and carets, met over the
12# network (resources/live-share.md). Runs only where SNOWBOUND_LIVE asks.
13live = ["notebook/live"]
1114
1215[dependencies]
1316canvas = { path = "../canvas", features = ["interaction", "pdf"] }
crates/snowbound/src/conflict_render.rs+1
......@@ -65,6 +65,7 @@ fn layout(ui: &mut Ui, session: &Session, menu: bool) -> Vec<Command> {
6565 &section,
6666 session,
6767 [&HashSet::new(); 2],
68 &HashMap::new(),
6869 (0.0, false),
6970 None,
7071 None,
crates/snowbound/src/live.rs created+472
......@@ -0,0 +1,472 @@
1//! Live presence in the window (feature `live`, on where `SNOWBOUND_LIVE` is `network` or
2//! `loopback`): the others with the notebook open as avatars beside the search box, a dot on
3//! the tab of the page each has open, and their carets on the page, each in a colour of their
4//! own. They meet through the notebook's identity, or through `SNOWBOUND_LIVE_CODE` where it
5//! is set (`resources/live-share.md`).
6
7use crate::{Command, Session, State, TAB_ROW, page, platform};
8use canvas::{document::TextPosition, editor::TextOutline};
9use notebook::live::{self, Caret, Hello, Peer, Reach, Room, Spot};
10use onestore::ExGuid;
11use std::{collections::HashMap, sync::OnceLock};
12use ui::{Flags, Spec, children, fill, fit, px};
13
14/// The side of the picture sent, in pixels.
15const PICTURE: u32 = 96;
16const AVATAR: f32 = 22.0;
17/// The name flag above a caret.
18const FLAG: f32 = 15.0;
19
20/// The room joined and the people met there.
21pub(crate) struct Peers {
22 room: Room,
23 /// None where joining failed, which is tried again only for another room.
24 live: Option<live::Live>,
25 /// Each peer's picture as drawn, cut to a circle, decoded once.
26 pictures: HashMap<[u8; 16], Option<draw::RasterImage>>,
27}
28
29fn reach() -> Option<Reach> {
30 match std::env::var("SNOWBOUND_LIVE").ok()?.as_str() {
31 "network" => Some(Reach::Network),
32 "loopback" => Some(Reach::Loopback),
33 _ => None,
34 }
35}
36
37impl State {
38 /// Joins the open notebook's room, leaving any other, and says where this window is.
39 pub(crate) fn follow_peers(&mut self) {
40 let Some(reach) = reach() else {
41 return;
42 };
43 let room = match std::env::var("SNOWBOUND_LIVE_CODE") {
44 Ok(code) => Some(Room::Code(code)),
45 Err(_) => self
46 .session
47 .as_ref()
48 .and_then(|session| session.library.catalog()?.toc.as_ref())
49 .map(|toc| Room::Notebook(toc.file_id)),
50 };
51 if self.peers.as_ref().map(|peers| &peers.room) != room.as_ref() {
52 self.peers = room.map(|room| {
53 let redraw = self.redraw.clone();
54 // The account's picture goes with the account's name, not another chosen in Options.
55 let picture = (self.author == platform::user_name())
56 .then(|| account_picture().clone())
57 .flatten();
58 let live = Hello::new(self.author.clone(), picture)
59 .and_then(|me| {
60 live::Live::start(me, &room, Some(reach), move || redraw.wake_by_ref())
61 })
62 .inspect_err(|error| eprintln!("Live presence: {error}"))
63 .ok();
64 Peers {
65 room,
66 live,
67 pictures: HashMap::new(),
68 }
69 });
70 }
71 if let Some(live) = self.peers.as_ref().and_then(|peers| peers.live.as_ref()) {
72 live.set_presence(self.presence());
73 }
74 }
75
76 /// The open section and page, and the caret where it is in stored text.
77 fn presence(&self) -> live::Presence {
78 let Some(session) = &self.session else {
79 return live::Presence::default();
80 };
81 let outline = self.view.editor.active_outline();
82 let [anchor, focus] = self
83 .view
84 .editor
85 .selection()
86 .positions
87 .map(|position| spot(outline, position));
88 live::Presence {
89 section: section(session),
90 page: Some(session.space.into()),
91 caret: anchor
92 .zip(focus)
93 .map(|(anchor, focus)| Caret { anchor, focus }),
94 }
95 }
96
97 fn connected(&self) -> Vec<Peer> {
98 self.peers
99 .as_ref()
100 .and_then(|peers| peers.live.as_ref())
101 .map(live::Live::peers)
102 .unwrap_or_default()
103 }
104
105 /// The colours of the peers on each page of the open section.
106 pub(crate) fn peer_pages(&self) -> HashMap<ExGuid, Vec<[f32; 4]>> {
107 let mut pages: HashMap<ExGuid, Vec<[f32; 4]>> = HashMap::new();
108 let Some(session) = &self.session else {
109 return pages;
110 };
111 for peer in self.connected() {
112 if let Some(presence) = peer
113 .presence
114 .filter(|presence| presence.section == section(session))
115 && let Some(page) = presence.page
116 {
117 pages
118 .entry(page.into())
119 .or_default()
120 .push(color(&peer.hello.peer));
121 }
122 }
123 pages
124 }
125
126 /// The others with the notebook open, as avatars leftward from the search box: a click
127 /// opens the page someone has open in this section.
128 pub(crate) fn avatars(&mut self) {
129 let peers = self.connected();
130 if peers.is_empty() {
131 return;
132 }
133 self.ui.open(
134 "peers",
135 Spec {
136 size: [children(), px(TAB_ROW)],
137 pad: [6.0, (TAB_ROW - AVATAR) / 2.0],
138 gap: 4.0,
139 ..Spec::default()
140 },
141 );
142 for peer in peers.iter().rev() {
143 let hello = &peer.hello;
144 let picture = self.peers.as_mut().and_then(|peers| {
145 peers
146 .pictures
147 .entry(hello.peer)
148 .or_insert_with(|| hello.picture.as_deref().and_then(circle))
149 .clone()
150 });
151 let avatar = self.ui.open(
152 hello.peer,
153 Spec {
154 flags: Flags::CLICKABLE,
155 size: [px(AVATAR); 2],
156 fill: Some(color(&hello.peer)),
157 radius: AVATAR / 2.0,
158 pad: [2.0, 2.0],
159 role: Some(accesskit::Role::Button),
160 ..Spec::default()
161 },
162 );
163 match &picture {
164 Some(image) => self.ui.leaf(
165 "picture",
166 Spec {
167 size: [fill(), fill()],
168 image: Some(image),
169 ..Spec::default()
170 },
171 ),
172 None => self.ui.leaf(
173 "initials",
174 Spec {
175 size: [fill(), fill()],
176 text: Some(&initials(&hello.name)),
177 font_size: Some(9.0),
178 bold: true,
179 center: true,
180 color: Some([1.0; 4]),
181 ..Spec::default()
182 },
183 ),
184 };
185 self.ui.close();
186 if let Some(node) = self.ui.access(avatar) {
187 node.set_label(hello.name.as_str());
188 }
189 let place = self.place_of(peer);
190 ui::popup::tooltip(&mut self.ui, &hello.name, "", place.as_deref());
191 if self.ui.signal(avatar).clicked
192 && let Some(session) = &self.session
193 && let Some(presence) = peer.presence.as_ref()
194 && presence.section == section(session)
195 && let Some(page) = presence.page
196 {
197 self.commands.push(Command::OpenPage(page.into()));
198 }
199 }
200 self.ui.close();
201 }
202
203 /// The page, or the section, `peer` has open.
204 fn place_of(&self, peer: &Peer) -> Option<String> {
205 let session = self.session.as_ref()?;
206 let presence = peer.presence.as_ref()?;
207 if presence.section == section(session) {
208 let page = ExGuid::from(presence.page?);
209 let (_, title, _) = session.pages.iter().find(|(space, ..)| *space == page)?;
210 return Some(if title.is_empty() {
211 "Untitled page".into()
212 } else {
213 title.clone()
214 });
215 }
216 let tab = session.tabs.iter().find(|tab| {
217 session.library.section_identity(&tab.path) == presence.section
218 && presence.section.is_some()
219 })?;
220 Some(format!("In {}", tab.name))
221 }
222
223 /// Each peer's caret on the open page with a flag naming them, and what they have
224 /// selected, as boxes floating in the page's box.
225 pub(crate) fn peer_carets(&mut self) {
226 let Some(session) = &self.session else {
227 return;
228 };
229 let Some([left, top, right, bottom]) = self.ui.rect(page()) else {
230 return;
231 };
232 let here = (section(session), Some(session.space.into()));
233 let scale = self.ui.scale();
234 let viewport = self.view.viewport;
235 // A point on the page in points from the page box's corner.
236 let shown = |[x, y]: [f32; 2]| {
237 [
238 (x * viewport.scale + viewport.origin[0]) / scale,
239 (y * viewport.scale + viewport.origin[1]) / scale,
240 ]
241 };
242 for peer in self.connected() {
243 let Some(caret) = peer
244 .presence
245 .filter(|presence| (presence.section, presence.page) == here)
246 .and_then(|presence| presence.caret)
247 else {
248 continue;
249 };
250 let Some((outline, focus)) = find(&self.view.editor, caret.focus) else {
251 continue;
252 };
253 let color = color(&peer.hello.peer);
254 let [x, y] = outline.origin();
255 if let Some((_, anchor)) =
256 find(&self.view.editor, caret.anchor).filter(|(other, _)| other.id == outline.id)
257 {
258 let rects = outline
259 .range_rects([anchor, focus].into())
260 .unwrap_or_default();
261 for (index, rect) in rects.into_iter().enumerate() {
262 let [x0, y0] = shown([rect.x0 as f32 + x, rect.y0 as f32 + y]);
263 let [x1, y1] = shown([rect.x1 as f32 + x, rect.y1 as f32 + y]);
264 self.ui.leaf(
265 (peer.hello.peer, "selection", index),
266 Spec {
267 flags: Flags::FLOAT,
268 position: [x0, y0],
269 size: [px(x1 - x0), px(y1 - y0)],
270 fill: Some([color[0], color[1], color[2], 0.25]),
271 ..Spec::default()
272 },
273 );
274 }
275 }
276 let Ok(rect) = outline.caret_at(focus, parley::Affinity::Downstream, 0.0) else {
277 continue;
278 };
279 let [x0, y0] = shown([rect.x0 as f32 + x, rect.y0 as f32 + y]);
280 let [_, y1] = shown([rect.x0 as f32 + x, rect.y1 as f32 + y]);
281 if !(0.0..right - left).contains(&x0) || y1 < 0.0 || y0 > bottom - top {
282 continue;
283 }
284 self.ui.leaf(
285 (peer.hello.peer, "caret"),
286 Spec {
287 flags: Flags::FLOAT,
288 position: [x0 - 1.0, y0],
289 size: [px(2.0), px(y1 - y0)],
290 fill: Some(color),
291 ..Spec::default()
292 },
293 );
294 let name = peer
295 .hello
296 .name
297 .split_whitespace()
298 .next()
299 .unwrap_or("Someone");
300 self.ui.leaf(
301 (peer.hello.peer, "flag"),
302 Spec {
303 flags: Flags::FLOAT,
304 position: [x0 - 1.0, y0 - FLAG],
305 size: [fit(), px(FLAG)],
306 text: Some(name),
307 font_size: Some(10.0),
308 bold: true,
309 color: Some([1.0; 4]),
310 fill: Some(color),
311 radius: 3.0,
312 pad: [4.0, 0.0],
313 ..Spec::default()
314 },
315 );
316 }
317 }
318}
319
320/// The open section's file identity.
321fn section(session: &Session) -> Option<[u8; 16]> {
322 session
323 .library
324 .section_identity(&session.tabs[session.tab].path)
325}
326
327/// `position` in `outline` as ops address it: its text object and offset.
328fn spot(outline: &TextOutline, position: TextPosition) -> Option<Spot> {
329 let text = outline
330 .document()
331 .text_nodes()
332 .nth(position.paragraph)?
333 .text()?;
334 Some(Spot {
335 text: text.id.into(),
336 offset: position.offset,
337 })
338}
339
340/// The outline on the page holding `spot`, and where in it.
341fn find(editor: &canvas::editor::CanvasEditor, spot: Spot) -> Option<(&TextOutline, TextPosition)> {
342 let text = ExGuid::from(spot.text);
343 editor.visible_outlines().find_map(|outline| {
344 let paragraph = outline
345 .document()
346 .text_nodes()
347 .position(|node| node.text().is_some_and(|object| object.id == text))?;
348 Some((
349 outline,
350 TextPosition {
351 paragraph,
352 offset: spot.offset,
353 },
354 ))
355 })
356}
357
358/// A peer's colour, the same in every window that shows them.
359fn color(peer: &[u8; 16]) -> [f32; 4] {
360 const COLORS: [[u8; 3]; 8] = [
361 [0x1a, 0x73, 0xe8],
362 [0xd9, 0x30, 0x25],
363 [0x18, 0x80, 0x38],
364 [0xe3, 0x74, 0x00],
365 [0x93, 0x34, 0xe6],
366 [0x00, 0x89, 0x7b],
367 [0xd0, 0x1b, 0x8c],
368 [0x80, 0x5a, 0x2e],
369 ];
370 let [red, green, blue] = COLORS[usize::from(peer[0]) % COLORS.len()];
371 draw::srgb(red, green, blue)
372}
373
374/// The first letters of a name's first and last words.
375fn initials(name: &str) -> String {
376 let words: Vec<&str> = name.split_whitespace().collect();
377 let first = |word: Option<&&str>| word.and_then(|word| word.chars().next());
378 [
379 first(words.first()),
380 first(words.last()).filter(|_| words.len() > 1),
381 ]
382 .into_iter()
383 .flatten()
384 .flat_map(char::to_uppercase)
385 .collect()
386}
387
388/// `picture`'s middle square, cut to a circle.
389fn circle(picture: &[u8]) -> Option<draw::RasterImage> {
390 let image = draw::RasterImage::decode(picture, [PICTURE; 2]).ok()?;
391 let [width, height] = image.size();
392 let side = width.min(height);
393 let [dx, dy] = [(width - side) / 2, (height - side) / 2];
394 let radius = side as f32 / 2.0;
395 let mut pixels = Vec::with_capacity((side * side * 4) as usize);
396 for y in 0..side {
397 for x in 0..side {
398 let at = (((y + dy) * width + x + dx) * 4) as usize;
399 let pixel = &image.pixels()[at..at + 4];
400 let distance = (x as f32 + 0.5 - radius).hypot(y as f32 + 0.5 - radius);
401 let coverage = (radius - distance + 0.5).clamp(0.0, 1.0);
402 pixels.extend_from_slice(&pixel[..3]);
403 pixels.push((f32::from(pixel[3]) * coverage) as u8);
404 }
405 }
406 draw::RasterImage::new([side; 2], pixels).ok()
407}
408
409/// The account's picture, as the system shows it at sign-in, as a PNG at most `PICTURE`
410/// pixels a side.
411fn account_picture() -> &'static Option<Vec<u8>> {
412 static PNG: OnceLock<Option<Vec<u8>>> = OnceLock::new();
413 PNG.get_or_init(|| {
414 let image = draw::RasterImage::decode(&system_picture()?, [PICTURE; 2]).ok()?;
415 let [width, height] = image.size();
416 let mut png = Vec::new();
417 let mut encoder = png::Encoder::new(&mut png, width, height);
418 encoder.set_color(png::ColorType::Rgba);
419 encoder.set_depth(png::BitDepth::Eight);
420 // Decoding premultiplies, which leaves an opaque photo as it was.
421 encoder
422 .write_header()
423 .ok()?
424 .write_image_data(image.pixels())
425 .ok()?;
426 Some(png)
427 })
428}
429
430/// Directory Services keeps the picture as hex under `JPEGPhoto`, or names a file.
431#[cfg(target_os = "macos")]
432fn system_picture() -> Option<Vec<u8>> {
433 let user = format!("/Users/{}", std::env::var("USER").ok()?);
434 let read = |attribute: &str| {
435 let output = std::process::Command::new("/usr/bin/dscl")
436 .args([".", "-read", &user, attribute])
437 .output()
438 .ok()?;
439 let text = String::from_utf8(output.stdout).ok()?;
440 Some(text.split_once(':')?.1.trim().to_owned())
441 };
442 if let Some(hex) = read("JPEGPhoto") {
443 let digits: Vec<u8> = hex.bytes().filter(u8::is_ascii_hexdigit).collect();
444 let bytes: Option<Vec<u8>> = digits
445 .chunks(2)
446 .map(|pair| u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok())
447 .collect();
448 if let Some(bytes) = bytes.filter(|bytes| !bytes.is_empty()) {
449 return Some(bytes);
450 }
451 }
452 notebook::fs::read(read("Picture")?).ok()
453}
454
455/// Where desktops keep it: `~/.face`, or AccountsService's icon for the account.
456#[cfg(target_os = "linux")]
457fn system_picture() -> Option<Vec<u8>> {
458 let home = std::path::PathBuf::from(std::env::var_os("HOME")?);
459 let user = std::env::var("USER").unwrap_or_default();
460 [
461 home.join(".face"),
462 home.join(".face.icon"),
463 std::path::Path::new("/var/lib/AccountsService/icons").join(user),
464 ]
465 .into_iter()
466 .find_map(|path| notebook::fs::read(path).ok())
467}
468
469#[cfg(not(any(target_os = "macos", target_os = "linux")))]
470fn system_picture() -> Option<Vec<u8>> {
471 None
472}
crates/snowbound/src/main.rs+40
......@@ -26,6 +26,8 @@ mod instance;
2626mod keys;
2727mod library;
2828mod link;
29#[cfg(feature = "live")]
30mod live;
2931#[cfg(target_os = "linux")]
3032#[path = "loader_linux.rs"]
3133mod loader;
......@@ -795,6 +797,8 @@ struct State {
795797 asking: std::collections::VecDeque<dialog::Dialog>,
796798 /// What has been read in each notebook, on this computer.
797799 reads: unread::Reads,
800 #[cfg(feature = "live")]
801 peers: Option<live::Peers>,
798802 /// Open Notebook from Server while it is open.
799803 server: Option<server::Connect>,
800804 /// New iCloud Notebook while it is open.
......@@ -1171,6 +1175,8 @@ impl State {
11711175 #[cfg(target_os = "linux")]
11721176 asking: Default::default(),
11731177 reads,
1178 #[cfg(feature = "live")]
1179 peers: None,
11741180 server: None,
11751181 new_icloud: None,
11761182 icloud_reading: HashSet::new(),
......@@ -1279,6 +1285,8 @@ impl State {
12791285 let response = self.view.scale_factor_changed(scale)?;
12801286 self.respond(response);
12811287 }
1288 #[cfg(feature = "live")]
1289 self.follow_peers();
12821290 self.layout(
12831291 [size.width, size.height].map(|side| side as f32 / self.window.scale_factor() as f32),
12841292 scale,
......@@ -1629,6 +1637,8 @@ impl State {
16291637 ..Spec::default()
16301638 },
16311639 );
1640 #[cfg(feature = "live")]
1641 self.avatars();
16321642 if self.session.is_some() {
16331643 self.page_tools(&theme);
16341644 }
......@@ -1750,6 +1760,10 @@ impl State {
17501760 self.respond(response);
17511761 }
17521762 }
1763 #[cfg(feature = "live")]
1764 if !opening {
1765 self.peer_carets();
1766 }
17531767 self.ui.close();
17541768 if let Some(task) = self.view.task_under_pointer() {
17551769 let [left, top, ..] = self.ui.rect(page()).unwrap_or_default();
......@@ -2948,6 +2962,10 @@ impl State {
29482962 }
29492963 let found = self.search.found_in(&session.key());
29502964 let unread = self.unread_pages();
2965 #[cfg(feature = "live")]
2966 let peers = self.peer_pages();
2967 #[cfg(not(feature = "live"))]
2968 let peers = HashMap::new();
29512969 let rounding = self.rounding();
29522970 let dragged = self.dragged_page();
29532971 let rows = page_rows(
......@@ -2956,6 +2974,7 @@ impl State {
29562974 section,
29572975 session,
29582976 [&found, &unread],
2977 &peers,
29592978 (rounding, self.page_tabs_left),
29602979 self.renaming.as_mut(),
29612980 dragged,
......@@ -4583,6 +4602,7 @@ fn page_rows(
45834602 section: &ui::Section,
45844603 session: &Session,
45854604 marked: [&HashSet<ExGuid>; 2],
4605 peers: &HashMap<ExGuid, Vec<[f32; 4]>>,
45864606 shape: (f32, bool),
45874607 mut renaming: Option<&mut rename::Renaming>,
45884608 dragged: Option<PageDrag>,
......@@ -4628,6 +4648,7 @@ fn page_rows(
46284648 section,
46294649 session,
46304650 marked,
4651 peers,
46314652 shape,
46324653 renaming.as_deref_mut(),
46334654 (space, title, *level),
......@@ -4655,6 +4676,7 @@ fn page_rows(
46554676 section,
46564677 session,
46574678 marked,
4679 peers,
46584680 shape,
46594681 renaming,
46604682 (space, title, *level),
......@@ -4676,6 +4698,7 @@ fn page_row(
46764698 section: &ui::Section,
46774699 session: &Session,
46784700 [found, unread]: [&HashSet<ExGuid>; 2],
4701 peers: &HashMap<ExGuid, Vec<[f32; 4]>>,
46794702 shape: (f32, bool),
46804703 renaming: Option<&mut rename::Renaming>,
46814704 (space, title, level): (&ExGuid, &String, u32),
......@@ -4696,6 +4719,7 @@ fn page_row(
46964719 conflicted: !versions.is_empty(),
46974720 found: found.contains(space),
46984721 unread: unread.contains(space),
4722 peers: peers.get(space).map_or(&[], Vec::as_slice),
46994723 renaming: renaming
47004724 .filter(|renaming| renaming.page(*space))
47014725 .map(|renaming| &mut renaming.name),
......@@ -4756,6 +4780,7 @@ fn page_row(
47564780 conflicted: false,
47574781 found: false,
47584782 unread: false,
4783 peers: &[],
47594784 renaming: None,
47604785 shift,
47614786 lifted: false,
......@@ -4788,6 +4813,8 @@ struct PageTab<'a> {
47884813 found: bool,
47894814 /// Another author changed it since it was last viewed, which OneNote sets bold.
47904815 unread: bool,
4816 /// The colours of the others who have it open.
4817 peers: &'a [[f32; 4]],
47914818 /// The name typed in the tab's rename field, while it shows one.
47924819 renaming: Option<&'a mut String>,
47934820 /// How far down from its place in the list it is drawn.
......@@ -4885,6 +4912,19 @@ fn page_tab(
48854912 None
48864913 }
48874914 };
4915 for (index, color) in tab.peers.iter().enumerate() {
4916 let inset = (ROW - ROW_GAP - 8.0) / 2.0;
4917 ui.leaf(
4918 ("peer", index),
4919 Spec {
4920 size: [px(10.0), px(ROW - ROW_GAP)],
4921 fill: Some(*color),
4922 radius: 4.0,
4923 inset: [1.0, inset, 1.0, inset],
4924 ..Spec::default()
4925 },
4926 );
4927 }
48884928 if tab.conflicted {
48894929 ui.leaf(
48904930 "conflict",