| author | |
| committer | |
| log | ab00bdcf0c7a9eddb387eab4ad09df0b862c7b32 |
| tree | dfe96636deee5ae2cb177578f9b2740c7722e1b2 |
| parent | f3bfb4de18ec03dca625dd95d5d5f2ae34383837 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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.513 files changed, 1495 insertions(+), 9 deletions(-)
AGENTS.md+1-1| ... | ... | @@ -13,7 +13,7 @@ novel UI kit, each new platform port is extremely lightweight. |
| 13 | 13 | | Path | Owns | Depends on | |
| 14 | 14 | | --- | --- | --- | |
| 15 | 15 | | `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 | | |
| 17 | 17 | | `crates/draw` | The wgpu renderer that page and chrome both paint through, and the text-editing core (keys, chords, carets) they share. | none | |
| 18 | 18 | | `crates/canvas` | The page: editor, OneNote-faithful layout, page scene, interaction, the page's accessibility tree. | onestore, draw | |
| 19 | 19 | | `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" |
| 908 | 908 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 909 | 909 | checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" |
| 910 | 910 | |
| 911 | [[package]] | |
| 912 | name = "curve25519-dalek" | |
| 913 | version = "5.0.0" | |
| 914 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 915 | checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" | |
| 916 | dependencies = [ | |
| 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]] | |
| 928 | name = "curve25519-dalek-derive" | |
| 929 | version = "0.1.1" | |
| 930 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 931 | checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" | |
| 932 | dependencies = [ | |
| 933 | "proc-macro2", | |
| 934 | "quote", | |
| 935 | "syn 2.0.119", | |
| 936 | ] | |
| 937 | ||
| 911 | 938 | [[package]] |
| 912 | 939 | name = "data-url" |
| 913 | 940 | version = "0.3.2" |
| ... | ... | @@ -1131,6 +1158,12 @@ dependencies = [ |
| 1131 | 1158 | "simd-adler32", |
| 1132 | 1159 | ] |
| 1133 | 1160 | |
| 1161 | [[package]] | |
| 1162 | name = "fiat-crypto" | |
| 1163 | version = "0.3.0" | |
| 1164 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1165 | checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" | |
| 1166 | ||
| 1134 | 1167 | [[package]] |
| 1135 | 1168 | name = "file-guard" |
| 1136 | 1169 | version = "0.2.0" |
| ... | ... | @@ -1164,6 +1197,15 @@ version = "0.9.0" |
| 1164 | 1197 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1165 | 1198 | checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" |
| 1166 | 1199 | |
| 1200 | [[package]] | |
| 1201 | name = "flume" | |
| 1202 | version = "0.12.0" | |
| 1203 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1204 | checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" | |
| 1205 | dependencies = [ | |
| 1206 | "spin", | |
| 1207 | ] | |
| 1208 | ||
| 1167 | 1209 | [[package]] |
| 1168 | 1210 | name = "foldhash" |
| 1169 | 1211 | version = "0.2.0" |
| ... | ... | @@ -1459,6 +1501,15 @@ version = "0.4.3" |
| 1459 | 1501 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1460 | 1502 | checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" |
| 1461 | 1503 | |
| 1504 | [[package]] | |
| 1505 | name = "hkdf" | |
| 1506 | version = "0.13.0" | |
| 1507 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1508 | checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" | |
| 1509 | dependencies = [ | |
| 1510 | "hmac", | |
| 1511 | ] | |
| 1512 | ||
| 1462 | 1513 | [[package]] |
| 1463 | 1514 | name = "hmac" |
| 1464 | 1515 | version = "0.13.0" |
| ... | ... | @@ -1625,6 +1676,16 @@ version = "2.3.0" |
| 1625 | 1676 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1626 | 1677 | checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" |
| 1627 | 1678 | |
| 1679 | [[package]] | |
| 1680 | name = "if-addrs" | |
| 1681 | version = "0.15.0" | |
| 1682 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1683 | checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" | |
| 1684 | dependencies = [ | |
| 1685 | "libc", | |
| 1686 | "windows-sys 0.61.2", | |
| 1687 | ] | |
| 1688 | ||
| 1628 | 1689 | [[package]] |
| 1629 | 1690 | name = "image" |
| 1630 | 1691 | version = "0.25.10" |
| ... | ... | @@ -1913,6 +1974,20 @@ version = "0.8.1" |
| 1913 | 1974 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1914 | 1975 | checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" |
| 1915 | 1976 | |
| 1977 | [[package]] | |
| 1978 | name = "mdns-sd" | |
| 1979 | version = "0.21.4" | |
| 1980 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1981 | checksum = "1067efe2aadc6967f84c95977dca910e98f3edfd6403efc8fa8508d289ce012d" | |
| 1982 | dependencies = [ | |
| 1983 | "fastrand", | |
| 1984 | "flume", | |
| 1985 | "if-addrs", | |
| 1986 | "mio", | |
| 1987 | "socket-pktinfo", | |
| 1988 | "socket2", | |
| 1989 | ] | |
| 1990 | ||
| 1916 | 1991 | [[package]] |
| 1917 | 1992 | name = "memchr" |
| 1918 | 1993 | version = "2.8.3" |
| ... | ... | @@ -1937,6 +2012,26 @@ dependencies = [ |
| 1937 | 2012 | "autocfg", |
| 1938 | 2013 | ] |
| 1939 | 2014 | |
| 2015 | [[package]] | |
| 2016 | name = "minicbor" | |
| 2017 | version = "2.3.0" | |
| 2018 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2019 | checksum = "c12b4033ffaa92fbf9df03df38d19324f52bad130dd223f811734a8006dd2d69" | |
| 2020 | dependencies = [ | |
| 2021 | "minicbor-derive", | |
| 2022 | ] | |
| 2023 | ||
| 2024 | [[package]] | |
| 2025 | name = "minicbor-derive" | |
| 2026 | version = "0.19.5" | |
| 2027 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2028 | checksum = "84f5ad8dfe10176465fe33f19ecfcf08783ba66630fdb40b2e4542547c1046f0" | |
| 2029 | dependencies = [ | |
| 2030 | "proc-macro2", | |
| 2031 | "quote", | |
| 2032 | "syn 2.0.119", | |
| 2033 | ] | |
| 2034 | ||
| 1940 | 2035 | [[package]] |
| 1941 | 2036 | name = "miniz_oxide" |
| 1942 | 2037 | version = "0.8.9" |
| ... | ... | @@ -1964,6 +2059,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1964 | 2059 | checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" |
| 1965 | 2060 | dependencies = [ |
| 1966 | 2061 | "libc", |
| 2062 | "log", | |
| 1967 | 2063 | "wasi", |
| 1968 | 2064 | "windows-sys 0.61.2", |
| 1969 | 2065 | ] |
| ... | ... | @@ -2089,6 +2185,8 @@ dependencies = [ |
| 2089 | 2185 | "hmac", |
| 2090 | 2186 | "js-sys", |
| 2091 | 2187 | "libc", |
| 2188 | "mdns-sd", | |
| 2189 | "minicbor", | |
| 2092 | 2190 | "nix", |
| 2093 | 2191 | "onestore", |
| 2094 | 2192 | "rsqlite-vfs", |
| ... | ... | @@ -2097,6 +2195,7 @@ dependencies = [ |
| 2097 | 2195 | "serde_json", |
| 2098 | 2196 | "sha2", |
| 2099 | 2197 | "smb2", |
| 2198 | "spake2", | |
| 2100 | 2199 | "tempfile", |
| 2101 | 2200 | "thiserror 2.0.20", |
| 2102 | 2201 | "tokio", |
| ... | ... | @@ -3602,6 +3701,17 @@ dependencies = [ |
| 3602 | 3701 | "zeroize", |
| 3603 | 3702 | ] |
| 3604 | 3703 | |
| 3704 | [[package]] | |
| 3705 | name = "socket-pktinfo" | |
| 3706 | version = "0.4.1" | |
| 3707 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3708 | checksum = "612942246d0cc239cfd83af1dfd39be47f649208a3524e5e9da651910128e0ac" | |
| 3709 | dependencies = [ | |
| 3710 | "libc", | |
| 3711 | "socket2", | |
| 3712 | "windows-sys 0.61.2", | |
| 3713 | ] | |
| 3714 | ||
| 3605 | 3715 | [[package]] |
| 3606 | 3716 | name = "socket2" |
| 3607 | 3717 | version = "0.6.5" |
| ... | ... | @@ -3612,6 +3722,19 @@ dependencies = [ |
| 3612 | 3722 | "windows-sys 0.61.2", |
| 3613 | 3723 | ] |
| 3614 | 3724 | |
| 3725 | [[package]] | |
| 3726 | name = "spake2" | |
| 3727 | version = "0.5.0-pre.0" | |
| 3728 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3729 | checksum = "7d5601a88f45d069ad786f75c4fb13e7f127b0aadda913fb2cd65948d3c9a561" | |
| 3730 | dependencies = [ | |
| 3731 | "curve25519-dalek", | |
| 3732 | "getrandom 0.4.3", | |
| 3733 | "hkdf", | |
| 3734 | "rand_core 0.10.1", | |
| 3735 | "sha2", | |
| 3736 | ] | |
| 3737 | ||
| 3615 | 3738 | [[package]] |
| 3616 | 3739 | name = "spellbook" |
| 3617 | 3740 | version = "0.4.2" |
| ... | ... | @@ -3622,6 +3745,15 @@ dependencies = [ |
| 3622 | 3745 | "hashbrown 0.17.1", |
| 3623 | 3746 | ] |
| 3624 | 3747 | |
| 3748 | [[package]] | |
| 3749 | name = "spin" | |
| 3750 | version = "0.9.9" | |
| 3751 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3752 | checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" | |
| 3753 | dependencies = [ | |
| 3754 | "lock_api", | |
| 3755 | ] | |
| 3756 | ||
| 3625 | 3757 | [[package]] |
| 3626 | 3758 | name = "spirv" |
| 3627 | 3759 | version = "0.4.0+sdk-1.4.341.0" |
crates/canvas/src/editor.rs+18-8| ... | ... | @@ -529,6 +529,23 @@ impl TextOutline { |
| 529 | 529 | .into() |
| 530 | 530 | } |
| 531 | 531 | |
| 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 | ||
| 532 | 549 | /// Highlight rectangles of `selection` in the outline, in its coordinates. |
| 533 | 550 | pub fn range_rects(&self, selection: Selection) -> Result<Vec<BoundingBox>, EditError> { |
| 534 | 551 | let [anchor, focus] = selection.positions; |
| ... | ... | @@ -2989,14 +3006,7 @@ impl CanvasEditor { |
| 2989 | 3006 | affinity: Affinity, |
| 2990 | 3007 | width: f32, |
| 2991 | 3008 | ) -> 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) | |
| 3000 | 3010 | } |
| 3001 | 3011 | |
| 3002 | 3012 | pub fn move_selection( |
crates/notebook/Cargo.toml+6| ... | ... | @@ -6,6 +6,9 @@ publish = false |
| 6 | 6 | |
| 7 | 7 | [features] |
| 8 | 8 | smb = ["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). | |
| 11 | live = ["dep:mdns-sd", "dep:minicbor", "dep:spake2"] | |
| 9 | 12 | |
| 10 | 13 | [dependencies] |
| 11 | 14 | onestore = { path = "../onestore" } |
| ... | ... | @@ -23,6 +26,9 @@ aes-gcm = "0.11.1" |
| 23 | 26 | base64 = { version = "0.23.1", default-features = false, features = ["std"] } |
| 24 | 27 | getrandom = "0.4.3" |
| 25 | 28 | hmac = "0.13.0" |
| 29 | mdns-sd = { version = "0.21.4", default-features = false, optional = true } | |
| 30 | minicbor = { version = "2.3.0", features = ["derive", "alloc"], optional = true } | |
| 31 | spake2 = { version = "=0.5.0-pre.0", features = ["getrandom"], optional = true } | |
| 26 | 32 | # OneNote packages (.onepkg) are cabinet files. |
| 27 | 33 | cab = "0.6" |
| 28 | 34 | # 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 |
| 529 | 529 | OneNote 2010 clients and Samba; it does not establish on-device execution or |
| 530 | 530 | physical power-loss durability. |
| 531 | 531 | |
| 532 | ## Live presence (feature `live`) | |
| 533 | ||
| 534 | `live::Live::start(hello, room, reach, notify)` listens on a TCP port and, with a `Reach`, | |
| 535 | advertises `_snowbound._tcp` by mDNS on every network or on loopback alone, connecting to the | |
| 536 | peers 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. | |
| 538 | Peers meet through SPAKE2 on the room's secret, then every frame is AES-256-GCM under the keys | |
| 539 | it agreed: a message kind and a CBOR map (`live::wire`). A reader skips kinds and map keys it | |
| 540 | doesn't know, so later versions add both freely. `set_presence` says which section, page and | |
| 541 | caret this end has (text object and UTF-16 offset, as ops address text); a connection sends | |
| 542 | only the newest. `peers()` lists each connected peer's `Hello` (name, picture) and presence, | |
| 543 | and `notify` runs whenever that changes. Dropping the `Live` leaves. | |
| 544 | ||
| 532 | 545 | ## Queue measurement |
| 533 | 546 | |
| 534 | 547 | `cargo run -p notebook --release --example queue_scale -- NEW_DIRECTORY 1000` |
crates/notebook/src/lib.rs+2| ... | ... | @@ -4,6 +4,8 @@ |
| 4 | 4 | |
| 5 | 5 | pub mod discover; |
| 6 | 6 | pub mod fs; |
| 7 | #[cfg(feature = "live")] | |
| 8 | pub mod live; | |
| 7 | 9 | #[cfg(feature = "smb")] |
| 8 | 10 | pub mod smb; |
| 9 | 11 |
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 | ||
| 7 | pub mod wire; | |
| 8 | pub use wire::{Caret, Guid, Hello, Presence, Spot}; | |
| 9 | ||
| 10 | use mdns_sd::{IfKind, ServiceDaemon, ServiceEvent, ServiceInfo}; | |
| 11 | use sha2::{Digest, Sha256}; | |
| 12 | use 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 | }; | |
| 24 | use wire::{Sealer, Side, kind}; | |
| 25 | ||
| 26 | const SERVICE: &str = "_snowbound._tcp.local."; | |
| 27 | /// How long a connection may be quiet before a ping, and before the peer counts as gone. | |
| 28 | const PING: Duration = Duration::from_secs(15); | |
| 29 | const GONE: Duration = Duration::from_secs(45); | |
| 30 | const OPENING: Duration = Duration::from_secs(5); | |
| 31 | ||
| 32 | /// The secret peers meet through. | |
| 33 | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 34 | pub 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 | ||
| 43 | impl 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)] | |
| 62 | pub 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)] | |
| 70 | pub 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. | |
| 77 | pub struct Live { | |
| 78 | shared: Arc<Shared>, | |
| 79 | address: SocketAddr, | |
| 80 | daemon: Option<ServiceDaemon>, | |
| 81 | } | |
| 82 | ||
| 83 | struct 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)] | |
| 94 | struct 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 | ||
| 101 | struct Link { | |
| 102 | connection: u64, | |
| 103 | peer: Peer, | |
| 104 | wake: mpsc::Sender<()>, | |
| 105 | stream: TcpStream, | |
| 106 | } | |
| 107 | ||
| 108 | impl 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 | ||
| 194 | impl 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. | |
| 210 | fn 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 | ||
| 269 | impl 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 | ||
| 389 | fn hex(bytes: &[u8]) -> String { | |
| 390 | bytes.iter().map(|byte| format!("{byte:02x}")).collect() | |
| 391 | } | |
| 392 | ||
| 393 | #[cfg(test)] | |
| 394 | mod tests; |
crates/notebook/src/live/tests.rs created+146| ... | ... | @@ -0,0 +1,146 @@ |
| 1 | use super::*; | |
| 2 | use std::time::Instant; | |
| 3 | ||
| 4 | fn 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. | |
| 9 | fn 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 | ||
| 21 | fn 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] | |
| 45 | fn 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] | |
| 70 | fn 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] | |
| 92 | fn 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"] | |
| 140 | fn 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 | ||
| 6 | use aes_gcm::{Aes256Gcm, KeyInit, aead::Aead}; | |
| 7 | use hmac::{Hmac, Mac}; | |
| 8 | use minicbor::{Decode, Encode}; | |
| 9 | use sha2::Sha256; | |
| 10 | use spake2::{Ed25519Group, Identity, Password, Spake2}; | |
| 11 | use std::io::{self, Read, Write}; | |
| 12 | ||
| 13 | /// The opening's version. Frames after it never change shape; they grow by kinds and fields. | |
| 14 | pub const VERSION: u16 = 1; | |
| 15 | /// The largest block either side reads. | |
| 16 | const MOST: usize = 16 << 20; | |
| 17 | ||
| 18 | /// Message kinds. | |
| 19 | pub 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. | |
| 27 | pub 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)] | |
| 32 | pub 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 | ||
| 49 | impl 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)] | |
| 67 | pub 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)] | |
| 81 | pub 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)] | |
| 91 | pub 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)] | |
| 101 | pub struct Guid { | |
| 102 | #[cbor(n(0), with = "minicbor::bytes")] | |
| 103 | pub guid: [u8; 16], | |
| 104 | #[n(1)] | |
| 105 | pub n: u32, | |
| 106 | } | |
| 107 | ||
| 108 | impl 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 | ||
| 117 | impl 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)] | |
| 129 | struct 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. | |
| 140 | pub struct Sealer { | |
| 141 | cipher: Aes256Gcm, | |
| 142 | count: u64, | |
| 143 | } | |
| 144 | ||
| 145 | impl 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)] | |
| 195 | pub 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. | |
| 203 | pub 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 | ||
| 245 | fn 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 | ||
| 253 | fn 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 | ||
| 265 | fn 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 |
| 8 | 8 | default = ["wgpu"] |
| 9 | 9 | # Paints through wgpu; without it through OpenGL 2.1, for Mac OS X 10.6. |
| 10 | 10 | wgpu = ["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. | |
| 13 | live = ["notebook/live"] | |
| 11 | 14 | |
| 12 | 15 | [dependencies] |
| 13 | 16 | canvas = { 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> { |
| 65 | 65 | &section, |
| 66 | 66 | session, |
| 67 | 67 | [&HashSet::new(); 2], |
| 68 | &HashMap::new(), | |
| 68 | 69 | (0.0, false), |
| 69 | 70 | None, |
| 70 | 71 | 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 | ||
| 7 | use crate::{Command, Session, State, TAB_ROW, page, platform}; | |
| 8 | use canvas::{document::TextPosition, editor::TextOutline}; | |
| 9 | use notebook::live::{self, Caret, Hello, Peer, Reach, Room, Spot}; | |
| 10 | use onestore::ExGuid; | |
| 11 | use std::{collections::HashMap, sync::OnceLock}; | |
| 12 | use ui::{Flags, Spec, children, fill, fit, px}; | |
| 13 | ||
| 14 | /// The side of the picture sent, in pixels. | |
| 15 | const PICTURE: u32 = 96; | |
| 16 | const AVATAR: f32 = 22.0; | |
| 17 | /// The name flag above a caret. | |
| 18 | const FLAG: f32 = 15.0; | |
| 19 | ||
| 20 | /// The room joined and the people met there. | |
| 21 | pub(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 | ||
| 29 | fn 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 | ||
| 37 | impl 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. | |
| 321 | fn 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. | |
| 328 | fn 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. | |
| 341 | fn 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. | |
| 359 | fn 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. | |
| 375 | fn 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. | |
| 389 | fn 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. | |
| 411 | fn 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")] | |
| 432 | fn 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")] | |
| 457 | fn 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")))] | |
| 470 | fn system_picture() -> Option<Vec<u8>> { | |
| 471 | None | |
| 472 | } |
crates/snowbound/src/main.rs+40| ... | ... | @@ -26,6 +26,8 @@ mod instance; |
| 26 | 26 | mod keys; |
| 27 | 27 | mod library; |
| 28 | 28 | mod link; |
| 29 | #[cfg(feature = "live")] | |
| 30 | mod live; | |
| 29 | 31 | #[cfg(target_os = "linux")] |
| 30 | 32 | #[path = "loader_linux.rs"] |
| 31 | 33 | mod loader; |
| ... | ... | @@ -795,6 +797,8 @@ struct State { |
| 795 | 797 | asking: std::collections::VecDeque<dialog::Dialog>, |
| 796 | 798 | /// What has been read in each notebook, on this computer. |
| 797 | 799 | reads: unread::Reads, |
| 800 | #[cfg(feature = "live")] | |
| 801 | peers: Option<live::Peers>, | |
| 798 | 802 | /// Open Notebook from Server while it is open. |
| 799 | 803 | server: Option<server::Connect>, |
| 800 | 804 | /// New iCloud Notebook while it is open. |
| ... | ... | @@ -1171,6 +1175,8 @@ impl State { |
| 1171 | 1175 | #[cfg(target_os = "linux")] |
| 1172 | 1176 | asking: Default::default(), |
| 1173 | 1177 | reads, |
| 1178 | #[cfg(feature = "live")] | |
| 1179 | peers: None, | |
| 1174 | 1180 | server: None, |
| 1175 | 1181 | new_icloud: None, |
| 1176 | 1182 | icloud_reading: HashSet::new(), |
| ... | ... | @@ -1279,6 +1285,8 @@ impl State { |
| 1279 | 1285 | let response = self.view.scale_factor_changed(scale)?; |
| 1280 | 1286 | self.respond(response); |
| 1281 | 1287 | } |
| 1288 | #[cfg(feature = "live")] | |
| 1289 | self.follow_peers(); | |
| 1282 | 1290 | self.layout( |
| 1283 | 1291 | [size.width, size.height].map(|side| side as f32 / self.window.scale_factor() as f32), |
| 1284 | 1292 | scale, |
| ... | ... | @@ -1629,6 +1637,8 @@ impl State { |
| 1629 | 1637 | ..Spec::default() |
| 1630 | 1638 | }, |
| 1631 | 1639 | ); |
| 1640 | #[cfg(feature = "live")] | |
| 1641 | self.avatars(); | |
| 1632 | 1642 | if self.session.is_some() { |
| 1633 | 1643 | self.page_tools(&theme); |
| 1634 | 1644 | } |
| ... | ... | @@ -1750,6 +1760,10 @@ impl State { |
| 1750 | 1760 | self.respond(response); |
| 1751 | 1761 | } |
| 1752 | 1762 | } |
| 1763 | #[cfg(feature = "live")] | |
| 1764 | if !opening { | |
| 1765 | self.peer_carets(); | |
| 1766 | } | |
| 1753 | 1767 | self.ui.close(); |
| 1754 | 1768 | if let Some(task) = self.view.task_under_pointer() { |
| 1755 | 1769 | let [left, top, ..] = self.ui.rect(page()).unwrap_or_default(); |
| ... | ... | @@ -2948,6 +2962,10 @@ impl State { |
| 2948 | 2962 | } |
| 2949 | 2963 | let found = self.search.found_in(&session.key()); |
| 2950 | 2964 | 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(); | |
| 2951 | 2969 | let rounding = self.rounding(); |
| 2952 | 2970 | let dragged = self.dragged_page(); |
| 2953 | 2971 | let rows = page_rows( |
| ... | ... | @@ -2956,6 +2974,7 @@ impl State { |
| 2956 | 2974 | section, |
| 2957 | 2975 | session, |
| 2958 | 2976 | [&found, &unread], |
| 2977 | &peers, | |
| 2959 | 2978 | (rounding, self.page_tabs_left), |
| 2960 | 2979 | self.renaming.as_mut(), |
| 2961 | 2980 | dragged, |
| ... | ... | @@ -4583,6 +4602,7 @@ fn page_rows( |
| 4583 | 4602 | section: &ui::Section, |
| 4584 | 4603 | session: &Session, |
| 4585 | 4604 | marked: [&HashSet<ExGuid>; 2], |
| 4605 | peers: &HashMap<ExGuid, Vec<[f32; 4]>>, | |
| 4586 | 4606 | shape: (f32, bool), |
| 4587 | 4607 | mut renaming: Option<&mut rename::Renaming>, |
| 4588 | 4608 | dragged: Option<PageDrag>, |
| ... | ... | @@ -4628,6 +4648,7 @@ fn page_rows( |
| 4628 | 4648 | section, |
| 4629 | 4649 | session, |
| 4630 | 4650 | marked, |
| 4651 | peers, | |
| 4631 | 4652 | shape, |
| 4632 | 4653 | renaming.as_deref_mut(), |
| 4633 | 4654 | (space, title, *level), |
| ... | ... | @@ -4655,6 +4676,7 @@ fn page_rows( |
| 4655 | 4676 | section, |
| 4656 | 4677 | session, |
| 4657 | 4678 | marked, |
| 4679 | peers, | |
| 4658 | 4680 | shape, |
| 4659 | 4681 | renaming, |
| 4660 | 4682 | (space, title, *level), |
| ... | ... | @@ -4676,6 +4698,7 @@ fn page_row( |
| 4676 | 4698 | section: &ui::Section, |
| 4677 | 4699 | session: &Session, |
| 4678 | 4700 | [found, unread]: [&HashSet<ExGuid>; 2], |
| 4701 | peers: &HashMap<ExGuid, Vec<[f32; 4]>>, | |
| 4679 | 4702 | shape: (f32, bool), |
| 4680 | 4703 | renaming: Option<&mut rename::Renaming>, |
| 4681 | 4704 | (space, title, level): (&ExGuid, &String, u32), |
| ... | ... | @@ -4696,6 +4719,7 @@ fn page_row( |
| 4696 | 4719 | conflicted: !versions.is_empty(), |
| 4697 | 4720 | found: found.contains(space), |
| 4698 | 4721 | unread: unread.contains(space), |
| 4722 | peers: peers.get(space).map_or(&[], Vec::as_slice), | |
| 4699 | 4723 | renaming: renaming |
| 4700 | 4724 | .filter(|renaming| renaming.page(*space)) |
| 4701 | 4725 | .map(|renaming| &mut renaming.name), |
| ... | ... | @@ -4756,6 +4780,7 @@ fn page_row( |
| 4756 | 4780 | conflicted: false, |
| 4757 | 4781 | found: false, |
| 4758 | 4782 | unread: false, |
| 4783 | peers: &[], | |
| 4759 | 4784 | renaming: None, |
| 4760 | 4785 | shift, |
| 4761 | 4786 | lifted: false, |
| ... | ... | @@ -4788,6 +4813,8 @@ struct PageTab<'a> { |
| 4788 | 4813 | found: bool, |
| 4789 | 4814 | /// Another author changed it since it was last viewed, which OneNote sets bold. |
| 4790 | 4815 | unread: bool, |
| 4816 | /// The colours of the others who have it open. | |
| 4817 | peers: &'a [[f32; 4]], | |
| 4791 | 4818 | /// The name typed in the tab's rename field, while it shows one. |
| 4792 | 4819 | renaming: Option<&'a mut String>, |
| 4793 | 4820 | /// How far down from its place in the list it is drawn. |
| ... | ... | @@ -4885,6 +4912,19 @@ fn page_tab( |
| 4885 | 4912 | None |
| 4886 | 4913 | } |
| 4887 | 4914 | }; |
| 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 | } | |
| 4888 | 4928 | if tab.conflicted { |
| 4889 | 4929 | ui.leaf( |
| 4890 | 4930 | "conflict", |