authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 18:24:39-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 19:55:46-07:00
log3d84683db457cfb9c19b314a937cbce6b1b8091d
tree760220ced5c5d092c9b438c868459165b543666a
parente01c7cf1781d73fb57bc66e80bb1b3da9cafa9d2
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: Live Share in the app (beta), and live presence on by default

File ▸ Live Share… (BETA) and the notebook's context menu share it: Start Sharing gives a code like 412-violet-otter, optionally with a password, then lists who is connected; Stop Sharing lets everyone go and retires the code and secret. Open Shared Notebook… (File menu and the welcome screen) takes the code and opens the notebook through the host, kept at live://<share> and opened from its last listing while the host is away; a share and the notebooks joined come back after a relaunch. Wrong codes, codes no one shares, expired codes, too many tries and an unreachable relay each say so. Presence is on for notebooks others reach too (on a server, in iCloud Drive, in another computer's folder, by Live Share), in the shown notebook's room only, and the mark beside the avatars says others see you. Options ▸ Sync & Storage ▸ Live Share turns it off, leaves the account picture out, or names another relay. Menu items take a badge (ui::badge); macOS's bundle lists _snowbound._tcp for Bonjour. Assisted-by: claude-opus-5.5

16 files changed, 1441 insertions(+), 98 deletions(-)

arc/sync.md+23-1
...@@ -312,6 +312,27 @@ compare-and-swap, either side of a race winning, late deliveries, offline spans)...@@ -312,6 +312,27 @@ compare-and-swap, either side of a race winning, late deliveries, offline spans)
312every device ends on the same file with each typed string once. OneNote 2010 cold-opens a312every device ends on the same file with each typed string once. OneNote 2010 cold-opens a
313merged section with its conflict page as its own (`corpus/icloud-merge`).313merged section with its conflict page as its own (`corpus/icloud-merge`).
314314
315## Live Share
316
317Live Share (`notebook::live::share`, beta) opens a notebook one Snowbound holds on others,
318with no service holding the notebook. The host serves its notebook's storage verbs to the peers
319in the share's room; a guest runs the replica, queue and merge it runs on an SMB share against
320them (`Notebook::open_hosted`, `Section::resume_hosted`, `Background::hosted`), so offline
321queueing, rebases and conflict pages behave as on a share, and the host's files are only ever
322written by its own storage, OneNote's locks included. A guest meets the host first in the
323room of a short code (`412-violet-otter`, SPAKE2 on its words and any password) through
324Snowbound's relay or by mDNS, and is welcomed with the share's room and its random secret;
325stopping or restarting a share retires the secret. A guest's commit is checked on the host's
326image before it is committed, and a guest can name nothing outside the notebook. Large reads
327and uploads travel 128 KiB at a time, at most 512 KiB unanswered, so a relay never holds much
328for a slow peer. A host that goes away leaves its guests working offline: the notebook opens
329from its last listing and edits publish when it is back. The relay is
330`crates/relay`.
331
332Live presence, on by default, shows who else has a notebook others reach too (on a server, in
333iCloud Drive, in another computer's folder, or by Live Share): their avatars, page and caret.
334Its room's secret is a random one in `.snowbound/live.json`, which Live Share never serves.
335
315## Notebook structure336## Notebook structure
316337
317Sections, groups and the notebook's own colour live in the `.onetoc2` files,338Sections, groups and the notebook's own colour live in the `.onetoc2` files,
...@@ -329,7 +350,8 @@ Windows hidden attribute so that OneNote never makes a section group of it. Its...@@ -329,7 +350,8 @@ Windows hidden attribute so that OneNote never makes a section group of it. Its
329plain files, not revision stores: pictures named by their content, and small JSON350plain files, not revision stores: pictures named by their content, and small JSON
330mappings that every writer merges before replacing. The first is tag art: a tag keeps351mappings that every writer merges before replacing. The first is tag art: a tag keeps
331OneNote's definition and a fallback symbol on the page, and `tags.json` maps its name and352OneNote's definition and a fallback symbol on the page, and `tags.json` maps its name and
332symbol to a picture that Snowbound draws in the symbol's place.353symbol to a picture that Snowbound draws in the symbol's place. `live.json` holds the
354notebook's presence room secret.
333355
334## Recovery and migration356## Recovery and migration
335357
crates/snowbound/src/commands.rs+29
...@@ -19,6 +19,10 @@ pub enum Id {...@@ -19,6 +19,10 @@ pub enum Id {
19 NewNotebook,19 NewNotebook,
20 OpenNotebook,20 OpenNotebook,
21 OpenFromServer,21 OpenFromServer,
22 /// Open Shared Notebook: a notebook another computer shares by Live Share.
23 OpenShared,
24 /// Live Share on the notebook shown.
25 LiveShare,
22 CloseNotebook,26 CloseNotebook,
23 NewSection,27 NewSection,
24 NewSectionGroup,28 NewSectionGroup,
...@@ -255,6 +259,8 @@ pub const COMMANDS: &[Command] = &[...@@ -255,6 +259,8 @@ pub const COMMANDS: &[Command] = &[
255 row!(Id::NewNotebook, "New Notebook…", NONE, NONE),259 row!(Id::NewNotebook, "New Notebook…", NONE, NONE),
256 row!(Id::OpenNotebook, "Open Notebook…", &[cmd('o')], &[cmd('o')]),260 row!(Id::OpenNotebook, "Open Notebook…", &[cmd('o')], &[cmd('o')]),
257 row!(Id::OpenFromServer, "Open Notebook from Server…", NONE, NONE),261 row!(Id::OpenFromServer, "Open Notebook from Server…", NONE, NONE),
262 row!(Id::OpenShared, "Open Shared Notebook…", NONE, NONE),
263 row!(Id::LiveShare, "Live Share…", NONE, NONE),
258 row!(Id::CloseNotebook, "Close This Notebook", NONE, NONE),264 row!(Id::CloseNotebook, "Close This Notebook", NONE, NONE),
259 row!(Id::NewSection, "New Section", &[cmd('t')], &[cmd('t')]),265 row!(Id::NewSection, "New Section", &[cmd('t')], &[cmd('t')]),
260 row!(Id::NewSectionGroup, "New Section Group", NONE, NONE),266 row!(Id::NewSectionGroup, "New Section Group", NONE, NONE),
...@@ -1159,6 +1165,16 @@ impl State {...@@ -1159,6 +1165,16 @@ impl State {
1159 | Id::Help1165 | Id::Help
1160 | Id::CheckForUpdates => enabled(!modal),1166 | Id::CheckForUpdates => enabled(!modal),
1161 Id::CloseNotebook => enabled(!modal && self.notebook().is_some()),1167 Id::CloseNotebook => enabled(!modal && self.notebook().is_some()),
1168 Id::OpenShared => enabled(!modal && cfg!(feature = "live")),
1169 #[cfg(feature = "live")]
1170 Id::LiveShare => enabled(
1171 !modal
1172 && self.notebook().is_some_and(|library| {
1173 library.catalog().is_some() && library.joined.is_none()
1174 }),
1175 ),
1176 #[cfg(not(feature = "live"))]
1177 Id::LiveShare => enabled(false),
1162 Id::PasswordProtect => enabled(!modal && self.shown_section().is_some()),1178 Id::PasswordProtect => enabled(!modal && self.shown_section().is_some()),
1163 Id::SaveAs => enabled(!modal && self.notebook().is_some()),1179 Id::SaveAs => enabled(!modal && self.notebook().is_some()),
1164 Id::RecycleBin => Status {1180 Id::RecycleBin => Status {
...@@ -1452,6 +1468,19 @@ impl State {...@@ -1452,6 +1468,19 @@ impl State {
1452 Id::NewNotebook => Work::NewNotebook,1468 Id::NewNotebook => Work::NewNotebook,
1453 Id::OpenNotebook => Work::OpenNotebook,1469 Id::OpenNotebook => Work::OpenNotebook,
1454 Id::OpenFromServer => Work::OpenFromServer(None),1470 Id::OpenFromServer => Work::OpenFromServer(None),
1471 #[cfg(feature = "live")]
1472 Id::OpenShared => {
1473 self.open_shared();
1474 return Ok(());
1475 }
1476 #[cfg(feature = "live")]
1477 Id::LiveShare => {
1478 let library = Arc::clone(self.notebook().ok_or("No notebook is open")?);
1479 self.open_live_share(library);
1480 return Ok(());
1481 }
1482 #[cfg(not(feature = "live"))]
1483 Id::OpenShared | Id::LiveShare => return Ok(()),
1455 Id::CloseNotebook => {1484 Id::CloseNotebook => {
1456 Work::CloseNotebook(Arc::clone(self.notebook().ok_or("No notebook is open")?))1485 Work::CloseNotebook(Arc::clone(self.notebook().ok_or("No notebook is open")?))
1457 }1486 }
crates/snowbound/src/library.rs+79-3
...@@ -38,12 +38,15 @@ pub fn on_background(notify: impl Fn() + Send + Sync + 'static) {...@@ -38,12 +38,15 @@ pub fn on_background(notify: impl Fn() + Send + Sync + 'static) {
38 let _ = NOTIFY.set(Box::new(notify));38 let _ = NOTIFY.set(Box::new(notify));
39}39}
4040
41fn notify_background() {41pub(crate) fn notify_background() {
42 if let Some(notify) = NOTIFY.get() {42 if let Some(notify) = NOTIFY.get() {
43 notify();43 notify();
44 }44 }
45}45}
4646
47/// What the location of a notebook another computer shares by Live Share starts with.
48pub const SHARED: &str = "live://";
49
47/// How long an SMB request may take before the share counts as unreachable.50/// How long an SMB request may take before the share counts as unreachable.
48const TIMEOUT: Duration = Duration::from_secs(10);51const TIMEOUT: Duration = Duration::from_secs(10);
49/// The largest section file read whole over SMB.52/// The largest section file read whole over SMB.
...@@ -325,6 +328,9 @@ pub struct Library {...@@ -325,6 +328,9 @@ pub struct Library {
325 themes: Mutex<Option<(Instant, Arc<Themes>)>>,328 themes: Mutex<Option<(Instant, Arc<Themes>)>>,
326 /// The keys of its password-protected sections unlocked this run, kept as it is read again.329 /// The keys of its password-protected sections unlocked this run, kept as it is read again.
327 keys: Arc<Keys>,330 keys: Arc<Keys>,
331 /// The share a notebook another computer shares by Live Share is reached through.
332 #[cfg(feature = "live")]
333 pub joined: Option<Arc<crate::live::Joined>>,
328}334}
329335
330/// Keys of password-protected sections, by file identity, with when each section was last336/// Keys of password-protected sections, by file identity, with when each section was last
...@@ -348,6 +354,8 @@ impl Library {...@@ -348,6 +354,8 @@ impl Library {
348 kept: Mutex::new(crate::prefetch::Recent::new(KEPT)),354 kept: Mutex::new(crate::prefetch::Recent::new(KEPT)),
349 themes: Default::default(),355 themes: Default::default(),
350 keys: Default::default(),356 keys: Default::default(),
357 #[cfg(feature = "live")]
358 joined: None,
351 }359 }
352 }360 }
353361
...@@ -356,6 +364,9 @@ impl Library {...@@ -356,6 +364,9 @@ impl Library {
356 /// account the system keeps for the mount, as OneNote's own client coordinates with364 /// account the system keeps for the mount, as OneNote's own client coordinates with
357 /// OneNote; without that account it opens through the mount.365 /// OneNote; without that account it opens through the mount.
358 pub fn notebook(location: &str, cache: &Path) -> Self {366 pub fn notebook(location: &str, cache: &Path) -> Self {
367 if location.starts_with(SHARED) {
368 return Self::joined(location, cache);
369 }
359 if let Some(mount) = server_address(location) {370 if let Some(mount) = server_address(location) {
360 let login = match mount.user {371 let login = match mount.user {
361 Some(_) => crate::platform::smb_login(&mount),372 Some(_) => crate::platform::smb_login(&mount),
...@@ -404,6 +415,47 @@ impl Library {...@@ -404,6 +415,47 @@ impl Library {
404 }415 }
405 }416 }
406417
418 /// The notebook another computer shares at `location` by Live Share, as its last listing
419 /// has it until that computer answers.
420 #[cfg(feature = "live")]
421 fn joined(location: &str, cache: &Path) -> Self {
422 let mut library = Self::new(location, String::new(), cache);
423 match crate::live::Joined::open(location, cache) {
424 Ok((joined, notebook)) => {
425 let background = Background::hosted(Arc::clone(&joined.guest), notify_background)
426 .inspect_err(|error| eprintln!("Background sync did not start: {error}"))
427 .ok()
428 .map(|background| {
429 background.set_offline(offline());
430 Arc::new(background)
431 });
432 let mut notebook = notebook;
433 if let Some(background) = &background {
434 background.watch(notebook.replicas());
435 joined.follow(background);
436 }
437 library.name = display_name(cache, location).unwrap_or_else(|| joined.name.clone());
438 library.tag_art = Mutex::new(Arc::new(read_tag_art(&notebook)));
439 library.notebook = Ok(Some(notebook));
440 library.background = background;
441 library.joined = Some(Arc::new(joined));
442 }
443 Err((name, reason)) => {
444 library.name = display_name(cache, location).unwrap_or(name);
445 library.notebook = Err(reason);
446 }
447 }
448 library
449 }
450
451 #[cfg(not(feature = "live"))]
452 fn joined(location: &str, cache: &Path) -> Self {
453 Self {
454 notebook: Err("This version of Snowbound can’t open shared notebooks.".into()),
455 ..Self::new(location, "Shared notebook".into(), cache)
456 }
457 }
458
407 /// No server is in reach of the browser until a relay carries SMB to it.459 /// No server is in reach of the browser until a relay carries SMB to it.
408 #[cfg(target_arch = "wasm32")]460 #[cfg(target_arch = "wasm32")]
409 pub fn on_share(_: &str, _: Mount, _: Login, _: &Path) -> Result<Self, String> {461 pub fn on_share(_: &str, _: Mount, _: Login, _: &Path) -> Result<Self, String> {
...@@ -643,6 +695,13 @@ impl Library {...@@ -643,6 +695,13 @@ impl Library {
643695
644 /// This notebook read again, the way it was opened, for changing its structure.696 /// This notebook read again, the way it was opened, for changing its structure.
645 pub fn reopen(&self) -> Result<Notebook, Box<dyn Error>> {697 pub fn reopen(&self) -> Result<Notebook, Box<dyn Error>> {
698 #[cfg(feature = "live")]
699 if let Some(joined) = &self.joined {
700 return Ok(Notebook::open_hosted(
701 Arc::clone(&joined.guest),
702 &self.cache,
703 )?);
704 }
646 Ok(match &self.server {705 Ok(match &self.server {
647 #[cfg(not(target_arch = "wasm32"))]706 #[cfg(not(target_arch = "wasm32"))]
648 Some(server) => {707 Some(server) => {
...@@ -678,6 +737,8 @@ impl Library {...@@ -678,6 +737,8 @@ impl Library {
678 watch,737 watch,
679 tag_art: Mutex::new(self.tag_art()),738 tag_art: Mutex::new(self.tag_art()),
680 keys: Arc::clone(&self.keys),739 keys: Arc::clone(&self.keys),
740 #[cfg(feature = "live")]
741 joined: self.joined.clone(),
681 ..Self::new(&self.location, name, &self.cache)742 ..Self::new(&self.location, name, &self.cache)
682 }743 }
683 }744 }
...@@ -745,6 +806,18 @@ impl Library {...@@ -745,6 +806,18 @@ impl Library {
745 return Ok(kept);806 return Ok(kept);
746 }807 }
747 let opened = match (&self.notebook, &self.server) {808 let opened = match (&self.notebook, &self.server) {
809 #[cfg(feature = "live")]
810 (Ok(Some(notebook)), _) if let Some(joined) = &self.joined => {
811 let cache = notebook.replica_path(path)?;
812 notebook::fs::create_dir_all(cache.parent().unwrap_or(&self.cache))?;
813 notebook::Replica::open_or_create(&cache, key.as_ref(), || {
814 notebook.read_section(path)
815 })
816 .and_then(|replica| {
817 let guest = Arc::clone(&joined.guest);
818 Section::resume_hosted(path.to_owned(), replica, guest, notifier())
819 })
820 }
748 #[cfg(target_arch = "wasm32")]821 #[cfg(target_arch = "wasm32")]
749 (Ok(Some(_)), Some(_)) => unreachable!("The browser opens no notebook on a server"),822 (Ok(Some(_)), Some(_)) => unreachable!("The browser opens no notebook on a server"),
750 #[cfg(not(target_arch = "wasm32"))]823 #[cfg(not(target_arch = "wasm32"))]
...@@ -854,8 +927,7 @@ impl Library {...@@ -854,8 +927,7 @@ impl Library {
854 /// The notebook's folder or section file on this computer; none for a notebook opened927 /// The notebook's folder or section file on this computer; none for a notebook opened
855 /// straight from its server.928 /// straight from its server.
856 pub fn folder(&self) -> Option<&Path> {929 pub fn folder(&self) -> Option<&Path> {
857 server_address(&self.location)930 (server_address(&self.location).is_none() && !self.location.starts_with(SHARED))
858 .is_none()
859 .then(|| Path::new(&self.location))931 .then(|| Path::new(&self.location))
860 }932 }
861933
...@@ -872,6 +944,10 @@ impl Library {...@@ -872,6 +944,10 @@ impl Library {
872 /// Where the notebook lives, as its reader knows the place: the server and share, iCloud944 /// Where the notebook lives, as its reader knows the place: the server and share, iCloud
873 /// Drive, a drive or this computer, then each folder down to the notebook's, left out.945 /// Drive, a drive or this computer, then each folder down to the notebook's, left out.
874 pub fn place(&self) -> Vec<String> {946 pub fn place(&self) -> Vec<String> {
947 #[cfg(feature = "live")]
948 if let Some(joined) = &self.joined {
949 return vec![format!("{}’s computer", joined.host)];
950 }
875 let mut place: Vec<String> = match self951 let mut place: Vec<String> = match self
876 .server952 .server
877 .as_ref()953 .as_ref()
crates/snowbound/src/live.rs+550-68
...@@ -1,15 +1,25 @@...@@ -1,15 +1,25 @@
1//! Live presence in the window (feature `live`, on where `SNOWBOUND_LIVE` is `network` or1//! Live presence and Live Share in the window (feature `live`, `resources/live-share.md`).
2//! `loopback`, through the default relay unless `SNOWBOUND_LIVE_RELAY` names another or `off`): the others2//! In a notebook others reach too (on a server, in iCloud Drive, on another computer, or by
3//! with the notebook open as avatars beside the search box, a dot on the tab of the page each3//! Live Share), whoever else has it open shows as an avatar beside the search box, a dot on
4//! has open, and their carets on the page, each in a colour of their own. They meet through4//! the tab of their page and their caret on the page, each in a colour of their own; they see
5//! the notebook's identity, or through `SNOWBOUND_LIVE_CODE` where it is set; through a relay,5//! this window the same way unless Options turns that off. Live Share serves a notebook on
6//! its words alone ask the relay to number it (`resources/live-share.md`).6//! this computer to whoever types its code (`Host`), or opens one another computer shares
77//! (`Joined`), as `share.rs`'s dialogs start them.
8use crate::{Command, Session, State, TAB_ROW, page, platform};8
9use crate::{Command, Library, Session, State, TAB_ROW, page, platform, settings};
9use canvas::{document::TextPosition, editor::TextOutline};10use canvas::{document::TextPosition, editor::TextOutline};
10use notebook::live::{self, Caret, Hello, Peer, Reach, Room, Spot};11use notebook::live::{
12 self, Caret, Hello, Peer, Reach, Room, Spot,
13 share::{Guest, Host, Sharing},
14};
15use notebook::session::{Background, Notebook};
11use onestore::ExGuid;16use onestore::ExGuid;
12use std::{collections::HashMap, sync::OnceLock};17use std::{
18 collections::{BTreeMap, HashMap},
19 io,
20 path::{Path, PathBuf},
21 sync::{Arc, Mutex, OnceLock, Weak, mpsc},
22};
13use ui::{Flags, Spec, children, fill, fit, px};23use ui::{Flags, Spec, children, fill, fit, px};
1424
15/// The side of the picture sent, in pixels.25/// The side of the picture sent, in pixels.
...@@ -17,72 +27,465 @@ const PICTURE: u32 = 96;...@@ -17,72 +27,465 @@ const PICTURE: u32 = 96;
17const AVATAR: f32 = 22.0;27const AVATAR: f32 = 22.0;
18/// The name flag above a caret.28/// The name flag above a caret.
19const FLAG: f32 = 15.0;29const FLAG: f32 = 15.0;
30/// The relay used where Options names none.
31pub(crate) const DEFAULT_RELAY: &str = "wss://relay.snowbound.paperclover.net";
32/// How long opening a notebook joined for the first time waits for the computer sharing it.
33const FIRST_LISTING: std::time::Duration = std::time::Duration::from_secs(15);
2034
21/// The room joined and the people met there.35/// Who this computer is to others and where it meets them, as Options last said.
22pub(crate) struct Peers {36struct Me {
23 room: Room,37 name: String,
24 /// None where joining failed, which is tried again only for another room.38 picture: bool,
25 live: Option<live::Live>,39 relay: Option<String>,
26 /// Each peer's picture as drawn, cut to a circle, decoded once.40}
27 pictures: HashMap<[u8; 16], Option<draw::RasterImage>>,41
42static ME: Mutex<Option<Me>> = Mutex::new(None);
43
44/// Takes Options' name, picture and relay for every meeting from now on.
45pub(crate) fn configure(author: &str, options: &settings::Live) {
46 *ME.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(Me {
47 name: author.to_owned(),
48 picture: options.picture,
49 relay: options
50 .relay
51 .clone()
52 .filter(|relay| !relay.trim().is_empty()),
53 });
54}
55
56/// This computer's greeting: the user's name, and the account's picture where Options lets
57/// it go and the name is the account's.
58fn hello() -> io::Result<Hello> {
59 let me = ME.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
60 let (name, picture) = me.as_ref().map_or((platform::user_name(), true), |me| {
61 (me.name.clone(), me.picture)
62 });
63 let picture = (picture && name == platform::user_name())
64 .then(|| account_picture().clone())
65 .flatten();
66 Hello::new(name, picture)
28}67}
2968
30/// The relay used when `SNOWBOUND_LIVE_RELAY` names none; `off` keeps peers to the LAN.69/// The relay peers off this network meet through: `SNOWBOUND_LIVE_RELAY` (`off` for none),
31const DEFAULT_RELAY: &str = "wss://relay.snowbound.paperclover.net";70/// else Options', else Snowbound's.
71fn relay() -> Option<String> {
72 match std::env::var("SNOWBOUND_LIVE_RELAY") {
73 Ok(relay) if relay == "off" => None,
74 Ok(relay) => Some(relay),
75 Err(_) => Some(
76 ME.lock()
77 .unwrap_or_else(|poisoned| poisoned.into_inner())
78 .as_ref()
79 .and_then(|me| me.relay.clone())
80 .unwrap_or_else(|| DEFAULT_RELAY.to_owned()),
81 ),
82 }
83}
3284
85/// Where peers are looked for on this computer's networks: every network, or with
86/// `SNOWBOUND_LIVE=loopback` this computer alone, as two copies side by side in a test do.
33fn reach() -> Option<Reach> {87fn reach() -> Option<Reach> {
34 match std::env::var("SNOWBOUND_LIVE").ok()?.as_str() {88 match std::env::var("SNOWBOUND_LIVE").as_deref() {
35 "network" => Some(Reach::Network),89 Ok("loopback") => Some(Reach::Loopback),
36 "loopback" => Some(Reach::Loopback),90 Ok("off") => None,
37 _ => None,91 _ => Some(Reach::Network),
38 }92 }
39}93}
4094
95/// Where this computer keeps what it shares and what it joined: beside the replicas, which
96/// hold the same notebooks' contents.
97fn kept(cache: &Path, name: &str) -> PathBuf {
98 cache.join("live").join(name)
99}
100
101fn read_kept<T: serde::de::DeserializeOwned + Default>(file: &Path) -> T {
102 notebook::fs::read(file)
103 .ok()
104 .and_then(|bytes| serde_json::from_slice(&bytes).ok())
105 .unwrap_or_default()
106}
107
108/// Replaces `file` whole, readable by this account alone.
109fn keep(file: &Path, value: &impl serde::Serialize) -> io::Result<()> {
110 let folder = file.parent().unwrap_or(Path::new("."));
111 notebook::fs::create_dir_all(folder)?;
112 let partial = file.with_extension("partial");
113 let mut options = std::fs::OpenOptions::new();
114 options.write(true).create(true).truncate(true);
115 #[cfg(unix)]
116 std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
117 io::Write::write_all(
118 &mut options.open(&partial)?,
119 &serde_json::to_vec_pretty(value)?,
120 )?;
121 notebook::fs::rename(partial, file)
122}
123
124/// A notebook another computer shares, as this one joined it.
125#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
126struct Share {
127 share: [u8; 16],
128 secret: [u8; 16],
129 notebook: String,
130 host: String,
131 /// It has been listed here, so it opens without waiting for the host.
132 listed: bool,
133}
134
135const JOINED: &str = "joined.json";
136const HOSTING: &str = "hosting.json";
137
138/// A notebook another computer shares, open here: the way to it and its names.
139pub struct Joined {
140 pub guest: Arc<Guest>,
141 pub name: String,
142 /// The name of the person sharing it.
143 pub host: String,
144 background: OnceLock<Weak<Background>>,
145}
146
147impl Joined {
148 /// The notebook shared at `location` and kept in `cache`, or its name and why it can't
149 /// be read.
150 pub fn open(location: &str, cache: &Path) -> Result<(Self, Notebook), (String, String)> {
151 let file = kept(cache, JOINED);
152 let mut shares: BTreeMap<String, Share> = read_kept(&file);
153 let Some(share) = shares.get(location).cloned() else {
154 return Err((
155 "Shared notebook".into(),
156 "This computer no longer has the code for this notebook. Close it, then open it \
157 again with a new code."
158 .into(),
159 ));
160 };
161 let refused = |error: &dyn std::fmt::Display| (share.notebook.clone(), error.to_string());
162 let guest = hello()
163 .and_then(|me| {
164 Guest::start(
165 me,
166 share.share,
167 share.secret,
168 reach(),
169 relay().as_deref(),
170 crate::library::notify_background,
171 )
172 })
173 .map_err(|error| refused(&error))?;
174 if !share.listed {
175 let since = std::time::Instant::now();
176 while guest.host().is_none() && since.elapsed() < FIRST_LISTING {
177 std::thread::sleep(std::time::Duration::from_millis(50));
178 }
179 }
180 let notebook = Notebook::open_hosted(Arc::clone(&guest), cache).map_err(|_| {
181 refused(&format!(
182 "Can’t reach {}’s computer. Make sure Snowbound is open there, then try again.",
183 share.host
184 ))
185 })?;
186 if !share.listed {
187 shares.insert(
188 location.to_owned(),
189 Share {
190 listed: true,
191 ..share.clone()
192 },
193 );
194 if let Err(error) = keep(&file, &shares) {
195 eprintln!("Keeping the shared notebooks: {error}");
196 }
197 }
198 let joined = Self {
199 guest,
200 name: share.notebook,
201 host: share.host,
202 background: OnceLock::new(),
203 };
204 Ok((joined, notebook))
205 }
206
207 /// Wakes `background` whenever the host comes back.
208 pub fn follow(&self, background: &Arc<Background>) {
209 let _ = self.background.set(Arc::downgrade(background));
210 }
211}
212
213/// Meets whoever shares `code` (and `password`): the share it welcomes this computer to.
214pub(crate) fn join(
215 code: &str,
216 password: &str,
217) -> Result<live::wire::Welcome, live::share::Refusal> {
218 let me = hello().map_err(|_| live::share::Refusal::Unreachable)?;
219 live::share::join(me, code, password, reach(), relay().as_deref())
220}
221
222/// Keeps `welcome` as the notebook this computer joined: its location.
223pub(crate) fn joined(cache: &Path, welcome: live::wire::Welcome) -> io::Result<String> {
224 let location = live::share::location(&welcome.share);
225 let file = kept(cache, JOINED);
226 let mut shares: BTreeMap<String, Share> = read_kept(&file);
227 shares.insert(
228 location.clone(),
229 Share {
230 share: welcome.share,
231 secret: welcome.secret,
232 notebook: welcome.notebook,
233 host: welcome.host,
234 listed: false,
235 },
236 );
237 keep(&file, &shares)?;
238 Ok(location)
239}
240
241/// What a thread of its own sends the frame, and where the frame takes it.
242pub(crate) type Channel<T> = (mpsc::Sender<T>, mpsc::Receiver<T>);
243
244/// The presence room joined and the people met there, what this computer shares, and the
245/// people's pictures.
246#[derive(Default)]
247pub(crate) struct Peers {
248 /// The shown notebook's presence room: its location and secret, and the meeting, none
249 /// where joining failed.
250 room: Option<(String, [u8; 16], Option<live::Live>)>,
251 /// Each shared notebook's presence secret, by location, read on a thread of its own:
252 /// `None` while it reads or where it couldn't be read.
253 secrets: HashMap<String, Option<[u8; 16]>>,
254 found: Option<Channel<(String, Option<[u8; 16]>)>>,
255 /// The notebooks this computer shares, by location.
256 pub(crate) hosts: BTreeMap<String, Arc<Host>>,
257 /// Shares as kept for the next launch.
258 sharing: Option<BTreeMap<String, Sharing>>,
259 /// Shares starting on threads of their own, by location.
260 pub(crate) starting: HashMap<String, Option<String>>,
261 started: Option<Channel<(String, Result<Host, String>)>>,
262 /// Whether each joined notebook's host was there last frame.
263 reached: HashMap<String, bool>,
264 /// Each peer's picture as drawn, cut to a circle, decoded once.
265 pictures: HashMap<[u8; 16], Option<draw::RasterImage>>,
266 pub(crate) share: Option<crate::share::ShareDialog>,
267 pub(crate) join: Option<crate::share::JoinDialog>,
268}
269
41impl State {270impl State {
42 /// Joins the open notebook's room, leaving any other, and says where this window is.271 /// Keeps the presence rooms and shares in step with what is open, and says where this
272 /// window is: in the shown notebook's room, and in each Live Share's.
43 pub(crate) fn follow_peers(&mut self) {273 pub(crate) fn follow_peers(&mut self) {
44 let (reach, relay) = (reach(), std::env::var("SNOWBOUND_LIVE_RELAY").ok());274 self.follow_shares();
45 if reach.is_none() && relay.is_none() {275 let shown = self
46 return;276 .session
47 }277 .as_ref()
48 let relay = match relay.as_deref() {278 .map(|session| Arc::clone(&session.library));
49 Some("off") => None,279 let here = if self.live_options.presence {
50 Some(_) => relay,280 self.presence()
51 None => Some(DEFAULT_RELAY.to_owned()),281 } else {
282 live::Presence::default()
52 };283 };
53 let room = match std::env::var("SNOWBOUND_LIVE_CODE") {284 let presence = |location: &str| match &shown {
54 Ok(code) => Some(Room::join(&code, "")),285 Some(shown) if shown.location == location => here.clone(),
55 Err(_) => self286 _ => live::Presence::default(),
56 .session
57 .as_ref()
58 .and_then(|session| session.library.catalog()?.toc.as_ref())
59 .map(|toc| Room::Notebook(toc.file_id)),
60 };287 };
61 if self.peers.as_ref().map(|peers| &peers.room) != room.as_ref() {288 for (location, host) in &self.peers.hosts {
62 self.peers = room.map(|room| {289 host.set_presence(presence(location));
290 }
291 for library in &self.notebooks {
292 if let Some(joined) = &library.joined {
293 joined.guest.set_presence(presence(&library.location));
294 }
295 }
296 let room = shown
297 .filter(|library| self.live_options.presence && reached_by_others(library))
298 .and_then(|library| Some((library.location.clone(), self.secret(&library)?)));
299 let current =
300 (self.peers.room.as_ref()).map(|(location, secret, _)| (location.clone(), *secret));
301 if current != room {
302 self.peers.room = room.map(|(location, secret)| {
63 let redraw = self.redraw.clone();303 let redraw = self.redraw.clone();
64 // The account's picture goes with the account's name, not another chosen in Options.304 let live = hello()
65 let picture = (self.author == platform::user_name())
66 .then(|| account_picture().clone())
67 .flatten();
68 let live = Hello::new(self.author.clone(), picture)
69 .and_then(|me| {305 .and_then(|me| {
70 live::Live::start(me, &room, reach, relay.as_deref(), move |_| {306 live::Live::start(
71 redraw.wake_by_ref()307 me,
72 })308 &Room::Notebook(secret),
309 reach(),
310 relay().as_deref(),
311 move |_| redraw.wake_by_ref(),
312 )
73 })313 })
74 .inspect_err(|error| eprintln!("Live presence: {error}"))314 .inspect_err(|error| eprintln!("Live presence: {error}"))
75 .ok();315 .ok();
76 Peers {316 (location, secret, live)
77 room,
78 live,
79 pictures: HashMap::new(),
80 }
81 });317 });
82 }318 }
83 if let Some(live) = self.peers.as_ref().and_then(|peers| peers.live.as_ref()) {319 if let Some((.., Some(live))) = &self.peers.room {
84 live.set_presence(self.presence());320 live.set_presence(here);
321 }
322 }
323
324 /// The presence secret of `library`, read on a thread of its own the first time.
325 fn secret(&mut self, library: &Arc<Library>) -> Option<[u8; 16]> {
326 let (found, arrived) = self.peers.found.get_or_insert_with(mpsc::channel);
327 for (location, secret) in arrived.try_iter() {
328 self.peers.secrets.insert(location, secret);
85 }329 }
330 if let Some(secret) = self.peers.secrets.get(&library.location) {
331 return *secret;
332 }
333 self.peers.secrets.insert(library.location.clone(), None);
334 let (found, library, redraw) = (found.clone(), Arc::clone(library), self.redraw.clone());
335 crate::spawn(move || {
336 let secret = match &library.notebook {
337 Ok(Some(notebook)) => notebook
338 .presence_room()
339 .inspect_err(|error| {
340 eprintln!("{}: no presence room: {error}", library.location)
341 })
342 .ok(),
343 _ => None,
344 };
345 let _ = found.send((library.location.clone(), secret));
346 redraw.wake();
347 });
348 None
349 }
350
351 /// Starts the shares kept from before as their notebooks open, stops those whose
352 /// notebook closed, keeps each share's code for the next launch, and wakes a joined
353 /// notebook's sync when its host comes back.
354 fn follow_shares(&mut self) {
355 let (_, started) = self.peers.started.get_or_insert_with(mpsc::channel);
356 for (location, host) in started.try_iter().collect::<Vec<_>>() {
357 match host {
358 Ok(host) => {
359 self.peers.starting.remove(&location);
360 self.hosting(location, Arc::new(host));
361 }
362 Err(error) => {
363 eprintln!("{location}: sharing didn’t start: {error}");
364 self.peers.starting.insert(location, Some(error));
365 }
366 }
367 }
368 let cache = self.cache.clone();
369 let sharing = self
370 .peers
371 .sharing
372 .get_or_insert_with(|| read_kept(&kept(&cache, HOSTING)))
373 .clone();
374 let open: Vec<Arc<Library>> = self.notebooks.clone();
375 for library in &open {
376 if let Some(kept) = sharing.get(&library.location)
377 && !self.peers.hosts.contains_key(&library.location)
378 && !self.peers.starting.contains_key(&library.location)
379 && matches!(library.notebook, Ok(Some(_)))
380 {
381 self.start_sharing(library, kept.clone());
382 }
383 if let Some(joined) = &library.joined {
384 let reached = joined.guest.host().is_some();
385 let before = self.peers.reached.insert(library.location.clone(), reached);
386 if reached && before == Some(false) {
387 if let Some(background) = joined.background.get().and_then(Weak::upgrade) {
388 background.wake();
389 }
390 if let Some(session) = &self.session
391 && Arc::ptr_eq(&session.library, library)
392 {
393 session.section.wake();
394 }
395 }
396 }
397 }
398 let closed: Vec<String> = (self.peers.hosts.keys())
399 .filter(|location| !open.iter().any(|library| library.location == **location))
400 .cloned()
401 .collect();
402 for location in closed {
403 self.stop_sharing(&location);
404 }
405 let now: BTreeMap<String, Sharing> = (self.peers.hosts.iter())
406 .map(|(location, host)| (location.clone(), host.sharing()))
407 .collect();
408 let before: BTreeMap<String, Sharing> = (sharing.into_iter())
409 .filter(|(location, _)| {
410 !self.peers.starting.contains_key(location)
411 && open.iter().any(|library| library.location == *location)
412 })
413 .collect();
414 if now != before {
415 if let Err(error) = keep(&kept(&cache, HOSTING), &now) {
416 eprintln!("Keeping what this computer shares: {error}");
417 }
418 self.peers.sharing = Some(now);
419 }
420 }
421
422 /// Shares `library` as `sharing` says, on a thread of its own.
423 pub(crate) fn start_sharing(&mut self, library: &Arc<Library>, sharing: Sharing) {
424 self.peers.starting.insert(library.location.clone(), None);
425 let (started, _) = self.peers.started.get_or_insert_with(mpsc::channel);
426 let (started, library, redraw) =
427 (started.clone(), Arc::clone(library), self.redraw.clone());
428 crate::spawn(move || {
429 let host = (|| -> Result<Host, Box<dyn std::error::Error>> {
430 let storage = library.reopen()?.into_storage();
431 let told = redraw.clone();
432 Ok(Host::start(
433 storage,
434 hello()?,
435 sharing,
436 &library.name,
437 reach(),
438 relay().as_deref(),
439 move || told.wake_by_ref(),
440 )?)
441 })();
442 let _ = started.send((
443 library.location.clone(),
444 host.map_err(|error| error.to_string()),
445 ));
446 redraw.wake();
447 });
448 }
449
450 /// `host` shares the notebook at `location`: its folder's changes reach its guests.
451 fn hosting(&mut self, location: String, host: Arc<Host>) {
452 if let Some(background) = self
453 .library_at(&location)
454 .and_then(|library| library.background.clone())
455 {
456 let host = Arc::downgrade(&host);
457 background.on_touched(Some(Box::new(move |paths| {
458 if let Some(host) = host.upgrade() {
459 host.touched(paths);
460 }
461 })));
462 }
463 self.peers.hosts.insert(location, host);
464 }
465
466 /// Stops sharing the notebook at `location`: its guests are let go, and its code and
467 /// secret are never used again.
468 pub(crate) fn stop_sharing(&mut self, location: &str) {
469 self.peers.starting.remove(location);
470 if let Some(background) = self
471 .library_at(location)
472 .and_then(|library| library.background.clone())
473 {
474 background.on_touched(None);
475 }
476 if let Some(host) = self.peers.hosts.remove(location) {
477 crate::spawn(move || {
478 if let Some(host) = Arc::into_inner(host) {
479 host.stop();
480 }
481 });
482 }
483 }
484
485 fn library_at(&self, location: &str) -> Option<&Arc<Library>> {
486 self.notebooks
487 .iter()
488 .find(|library| library.location == location)
86 }489 }
87490
88 /// The open section and page, and the caret where it is in stored text.491 /// The open section and page, and the caret where it is in stored text.
...@@ -106,12 +509,32 @@ impl State {...@@ -106,12 +509,32 @@ impl State {
106 }509 }
107 }510 }
108511
512 /// Everyone with the shown notebook open: in its presence room, and in its Live Share,
513 /// as host or guest.
109 fn connected(&self) -> Vec<Peer> {514 fn connected(&self) -> Vec<Peer> {
110 self.peers515 let Some(library) = self.session.as_ref().map(|session| &session.library) else {
111 .as_ref()516 return Vec::new();
112 .and_then(|peers| peers.live.as_ref())517 };
113 .map(live::Live::peers)518 let mut peers: Vec<Peer> = Vec::new();
114 .unwrap_or_default()519 let rooms = (self.peers.room.iter())
520 .filter(|(location, ..)| *location == library.location)
521 .filter_map(|(.., live)| live.as_ref().map(live::Live::peers))
522 .chain(
523 self.peers
524 .hosts
525 .get(&library.location)
526 .map(|host| host.guests()),
527 )
528 .chain(library.joined.as_ref().map(|joined| joined.guest.peers()));
529 for peer in rooms.flatten() {
530 if !peers
531 .iter()
532 .any(|known| known.hello.peer == peer.hello.peer)
533 {
534 peers.push(peer);
535 }
536 }
537 peers
115 }538 }
116539
117 /// The colours of the peers on each page of the open section.540 /// The colours of the peers on each page of the open section.
...@@ -153,13 +576,10 @@ impl State {...@@ -153,13 +576,10 @@ impl State {
153 );576 );
154 for peer in peers.iter().rev() {577 for peer in peers.iter().rev() {
155 let hello = &peer.hello;578 let hello = &peer.hello;
156 let picture = self.peers.as_mut().and_then(|peers| {579 let picture = (self.peers.pictures)
157 peers580 .entry(hello.peer)
158 .pictures581 .or_insert_with(|| hello.picture.as_deref().and_then(circle))
159 .entry(hello.peer)582 .clone();
160 .or_insert_with(|| hello.picture.as_deref().and_then(circle))
161 .clone()
162 });
163 let avatar = self.ui.open(583 let avatar = self.ui.open(
164 hello.peer,584 hello.peer,
165 Spec {585 Spec {
...@@ -209,7 +629,59 @@ impl State {...@@ -209,7 +629,59 @@ impl State {
209 self.commands.push(Command::OpenPage(page.into()));629 self.commands.push(Command::OpenPage(page.into()));
210 }630 }
211 }631 }
632 if self.live_options.presence {
633 self.seen();
634 }
635 self.ui.close();
636 }
637
638 /// The mark beside the avatars that says the others see this window too, which opens
639 /// Options to turn that off.
640 fn seen(&mut self) {
641 let accent = self.ui.theme.accent;
642 let mark = self.ui.open(
643 "seen",
644 Spec {
645 flags: Flags::CLICKABLE,
646 size: [px(AVATAR); 2],
647 pad: [5.0, 5.0],
648 role: Some(accesskit::Role::Button),
649 ..Spec::default()
650 },
651 );
652 self.ui.open(
653 "ring",
654 Spec {
655 size: [fill(), fill()],
656 border: Some(accent),
657 radius: (AVATAR - 10.0) / 2.0,
658 pad: [3.5, 3.5],
659 ..Spec::default()
660 },
661 );
662 self.ui.leaf(
663 "dot",
664 Spec {
665 size: [fill(), fill()],
666 fill: Some(accent),
667 radius: (AVATAR - 17.0) / 2.0,
668 ..Spec::default()
669 },
670 );
671 self.ui.close();
212 self.ui.close();672 self.ui.close();
673 if let Some(node) = self.ui.access(mark) {
674 node.set_label("Others see you here");
675 }
676 ui::popup::tooltip(
677 &mut self.ui,
678 "Others see you here",
679 "",
680 Some("Your name, page and cursor. Turn this off in Options."),
681 );
682 if self.ui.signal(mark).clicked {
683 self.open_options();
684 }
213 }685 }
214686
215 /// The page, or the section, `peer` has open.687 /// The page, or the section, `peer` has open.
...@@ -329,6 +801,16 @@ impl State {...@@ -329,6 +801,16 @@ impl State {
329 }801 }
330}802}
331803
804/// Whether others may have `library` open too: on a server, in iCloud Drive, or in a folder
805/// another computer holds.
806fn reached_by_others(library: &Library) -> bool {
807 library.on_smb()
808 || library.in_icloud()
809 || library
810 .folder()
811 .is_some_and(|folder| !crate::watch::on_this_computer(folder))
812}
813
332/// The open section's file identity.814/// The open section's file identity.
333fn section(session: &Session) -> Option<[u8; 16]> {815fn section(session: &Session) -> Option<[u8; 16]> {
334 session816 session
crates/snowbound/src/main.rs+22-2
...@@ -68,6 +68,8 @@ mod screenshot;...@@ -68,6 +68,8 @@ mod screenshot;
68mod search;68mod search;
69mod server;69mod server;
70mod settings;70mod settings;
71#[cfg(feature = "live")]
72mod share;
71mod sidebar;73mod sidebar;
72#[cfg_attr(target_os = "linux", path = "spell_linux.rs")]74#[cfg_attr(target_os = "linux", path = "spell_linux.rs")]
73#[cfg_attr(target_os = "macos", path = "spell_macos.rs")]75#[cfg_attr(target_os = "macos", path = "spell_macos.rs")]
...@@ -674,6 +676,9 @@ enum Command {...@@ -674,6 +676,9 @@ enum Command {
674 /// Asks for a server's address and opens a notebook on it; with a notebook's location,676 /// Asks for a server's address and opens a notebook on it; with a notebook's location,
675 /// signs in again to open it.677 /// signs in again to open it.
676 OpenFromServer(Option<String>),678 OpenFromServer(Option<String>),
679 /// Asks for the code of a notebook another computer shares, and opens it.
680 #[cfg(feature = "live")]
681 OpenShared,
677 /// Asks where to keep a new notebook and creates it.682 /// Asks where to keep a new notebook and creates it.
678 NewNotebook,683 NewNotebook,
679 /// Opens the Snowbound Guide from the user's documents, copying it there first.684 /// Opens the Snowbound Guide from the user's documents, copying it there first.
...@@ -819,7 +824,9 @@ struct State {...@@ -819,7 +824,9 @@ struct State {
819 /// What has been read in each notebook, on this computer.824 /// What has been read in each notebook, on this computer.
820 reads: unread::Reads,825 reads: unread::Reads,
821 #[cfg(feature = "live")]826 #[cfg(feature = "live")]
822 peers: Option<live::Peers>,827 peers: live::Peers,
828 /// Options' Live Share.
829 live_options: settings::Live,
823 /// Open Notebook from Server while it is open.830 /// Open Notebook from Server while it is open.
824 server: Option<server::Connect>,831 server: Option<server::Connect>,
825 /// Notebooks in iCloud Drive a thread is reading, or following the download of, by832 /// Notebooks in iCloud Drive a thread is reading, or following the download of, by
...@@ -992,6 +999,14 @@ impl State {...@@ -992,6 +999,14 @@ impl State {
992 // Beneath the surface's layer, which is added over it.999 // Beneath the surface's layer, which is added over it.
993 let backdrop = visible && platform::install_backdrop(&window);1000 let backdrop = visible && platform::install_backdrop(&window);
994 commands::Keymap::from_saved(&stored.keys).install();1001 commands::Keymap::from_saved(&stored.keys).install();
1002 #[cfg(feature = "live")]
1003 live::configure(
1004 stored
1005 .user_name
1006 .as_deref()
1007 .unwrap_or(&platform::user_name()),
1008 &stored.live,
1009 );
995 platform::install_menu();1010 platform::install_menu();
996 let access_adapter =1011 let access_adapter =
997 AccessAdapter::with_event_loop_proxy(event_loop, &window, proxy.clone());1012 AccessAdapter::with_event_loop_proxy(event_loop, &window, proxy.clone());
...@@ -1212,7 +1227,7 @@ impl State {...@@ -1212,7 +1227,7 @@ impl State {
1212 asking: Default::default(),1227 asking: Default::default(),
1213 reads,1228 reads,
1214 #[cfg(feature = "live")]1229 #[cfg(feature = "live")]
1215 peers: None,1230 peers: Default::default(),
1216 server: None,1231 server: None,
1217 icloud_reading: HashSet::new(),1232 icloud_reading: HashSet::new(),
1218 folder_rename: None,1233 folder_rename: None,
...@@ -1233,6 +1248,7 @@ impl State {...@@ -1233,6 +1248,7 @@ impl State {
1233 locked,1248 locked,
1234 password: None,1249 password: None,
1235 passwords: stored.passwords,1250 passwords: stored.passwords,
1251 live_options: stored.live,
1236 drag: None,1252 drag: None,
1237 templates: templates::View::Strip,1253 templates: templates::View::Strip,
1238 media: Default::default(),1254 media: Default::default(),
...@@ -1391,6 +1407,8 @@ impl State {...@@ -1391,6 +1407,8 @@ impl State {
1391 self.themes_dialog();1407 self.themes_dialog();
1392 self.print_dialog();1408 self.print_dialog();
1393 self.server_dialog();1409 self.server_dialog();
1410 #[cfg(feature = "live")]
1411 self.live_dialogs();
1394 self.link_dialog()?;1412 self.link_dialog()?;
1395 self.properties_dialog();1413 self.properties_dialog();
1396 self.symbols_dialog();1414 self.symbols_dialog();
...@@ -3369,6 +3387,8 @@ impl State {...@@ -3369,6 +3387,8 @@ impl State {
3369 }),3387 }),
3370 ),3388 ),
3371 Command::OpenFromServer(location) => self.open_server(location.as_deref()),3389 Command::OpenFromServer(location) => self.open_server(location.as_deref()),
3390 #[cfg(feature = "live")]
3391 Command::OpenShared => self.open_shared(),
3372 Command::NewNotebook => self.new_notebook(),3392 Command::NewNotebook => self.new_notebook(),
3373 Command::OpenGuide => self.open_guide()?,3393 Command::OpenGuide => self.open_guide()?,
3374 #[cfg(target_os = "linux")]3394 #[cfg(target_os = "linux")]
crates/snowbound/src/menubar.rs+7
...@@ -56,6 +56,7 @@ const MENUS: &[Item] = &[...@@ -56,6 +56,7 @@ const MENUS: &[Item] = &[
56 C(Id::NewNotebook),56 C(Id::NewNotebook),
57 C(Id::OpenNotebook),57 C(Id::OpenNotebook),
58 C(Id::OpenFromServer),58 C(Id::OpenFromServer),
59 C(Id::OpenShared),
59 C(Id::CloseNotebook),60 C(Id::CloseNotebook),
60 S,61 S,
61 C(Id::NewSection),62 C(Id::NewSection),
...@@ -66,6 +67,7 @@ const MENUS: &[Item] = &[...@@ -66,6 +67,7 @@ const MENUS: &[Item] = &[
66 C(Id::PageVersions),67 C(Id::PageVersions),
67 C(Id::CopyPageLink),68 C(Id::CopyPageLink),
68 C(Id::ShowNotebook),69 C(Id::ShowNotebook),
70 C(Id::LiveShare),
69 S,71 S,
70 C(Id::PasswordProtect),72 C(Id::PasswordProtect),
71 C(Id::LockAll),73 C(Id::LockAll),
...@@ -299,6 +301,11 @@ fn menu(...@@ -299,6 +301,11 @@ fn menu(
299 };301 };
300 for item in items {302 for item in items {
301 match item {303 match item {
304 // Live Share is still settling; AppKit's menus take no badge before macOS 14.
305 Item::Command(id @ (Id::LiveShare | Id::OpenShared)) => {
306 let title = format!("{} (Beta)", commands::command(*id).title);
307 choose(&Choice::Command(*id), &title, None);
308 }
302 Item::Command(id) => {309 Item::Command(id) => {
303 choose(310 choose(
304 &Choice::Command(*id),311 &Choice::Command(*id),
crates/snowbound/src/menus.rs+20
...@@ -122,6 +122,9 @@ pub enum Action {...@@ -122,6 +122,9 @@ pub enum Action {
122 Properties,122 Properties,
123 /// Opens Password Protection on a section.123 /// Opens Password Protection on a section.
124 Password,124 Password,
125 /// Opens Live Share on a notebook.
126 #[cfg(feature = "live")]
127 LiveShare,
125}128}
126129
127/// OneNote 2010's section and notebook colours, COLORREF, with their names in its Section130/// OneNote 2010's section and notebook colours, COLORREF, with their names in its Section
...@@ -272,11 +275,16 @@ impl State {...@@ -272,11 +275,16 @@ impl State {
272 // An action, its label, whether it is disabled, and whether a rule starts its group.275 // An action, its label, whether it is disabled, and whether a rule starts its group.
273 let item = |action: Action, text, disabled, separated| {276 let item = |action: Action, text, disabled, separated| {
274 let submenu = matches!(action, Action::Move | Action::Copy | Action::Colors);277 let submenu = matches!(action, Action::Move | Action::Copy | Action::Colors);
278 #[cfg(feature = "live")]
279 let badge = (action == Action::LiveShare).then_some(crate::share::BETA);
280 #[cfg(not(feature = "live"))]
281 let badge = None;
275 let item = Item {282 let item = Item {
276 text,283 text,
277 disabled,284 disabled,
278 separated,285 separated,
279 submenu,286 submenu,
287 badge,
280 ..Item::default()288 ..Item::default()
281 };289 };
282 (action, item)290 (action, item)
...@@ -402,6 +410,13 @@ impl State {...@@ -402,6 +410,13 @@ impl State {
402 false,410 false,
403 ),411 ),
404 item(Action::CopyLink, "Copy Link to Notebook", false, true),412 item(Action::CopyLink, "Copy Link to Notebook", false, true),
413 #[cfg(feature = "live")]
414 item(
415 Action::LiveShare,
416 commands::command(commands::Id::LiveShare).title,
417 library.catalog().is_none() || library.joined.is_some(),
418 false,
419 ),
405 item(Action::NewSection, "New Section", false, true),420 item(Action::NewSection, "New Section", false, true),
406 item(Action::NewGroup, "New Section Group", false, false),421 item(Action::NewGroup, "New Section Group", false, false),
407 item(422 item(
...@@ -724,6 +739,11 @@ impl State {...@@ -724,6 +739,11 @@ impl State {
724 None739 None
725 }740 }
726 (Target::Notebook(library), Action::Close) => Some(Command::CloseNotebook(library)),741 (Target::Notebook(library), Action::Close) => Some(Command::CloseNotebook(library)),
742 #[cfg(feature = "live")]
743 (Target::Notebook(library), Action::LiveShare) => {
744 self.open_live_share(library);
745 None
746 }
727 (Target::Notebook(library), Action::Rename | Action::Properties) => {747 (Target::Notebook(library), Action::Rename | Action::Properties) => {
728 self.open_properties(library);748 self.open_properties(library);
729 None749 None
crates/snowbound/src/options.rs+70-8
...@@ -178,14 +178,37 @@ const SECTIONS: &[Section] = &[...@@ -178,14 +178,37 @@ const SECTIONS: &[Section] = &[
178 },178 },
179 Section {179 Section {
180 name: "Sync & Storage",180 name: "Sync & Storage",
181 groups: &[Group {181 groups: &[
182 heading: "Cache file location",182 Group {
183 rows: &[Row {183 heading: "Cache file location",
184 label: "Path:",184 rows: &[Row {
185 keywords: "folder replica data disk",185 label: "Path:",
186 control: Control::Field(cache),186 keywords: "folder replica data disk",
187 }],187 control: Control::Field(cache),
188 }],188 }],
189 },
190 #[cfg(feature = "live")]
191 Group {
192 heading: "Live Share",
193 rows: &[
194 Row {
195 label: "Show others where I am in shared notebooks",
196 keywords: "presence privacy cursor caret avatar collaborate people",
197 control: Control::Check(|options| &mut options.presence),
198 },
199 Row {
200 label: "Show my account picture with my name",
201 keywords: "presence privacy photo avatar",
202 control: Control::Check(|options| &mut options.picture),
203 },
204 Row {
205 label: "Relay:",
206 keywords: "live share server internet network presence",
207 control: Control::Field(relay),
208 },
209 ],
210 },
211 ],
189 },212 },
190 Section {213 Section {
191 name: "Updates",214 name: "Updates",
...@@ -246,6 +269,10 @@ pub struct Options {...@@ -246,6 +269,10 @@ pub struct Options {
246 /// Minutes, as `protection::AFTER` lists them.269 /// Minutes, as `protection::AFTER` lists them.
247 lock_minutes: u32,270 lock_minutes: u32,
248 lock_on_leave: bool,271 lock_on_leave: bool,
272 presence: bool,
273 picture: bool,
274 /// Live Share's relay; empty uses Snowbound's.
275 relay: String,
249 pub(crate) keyboard: crate::keys::Keyboard,276 pub(crate) keyboard: crate::keys::Keyboard,
250}277}
251278
...@@ -342,6 +369,9 @@ impl State {...@@ -342,6 +369,9 @@ impl State {
342 lock_idle: self.passwords.lock_after.is_some(),369 lock_idle: self.passwords.lock_after.is_some(),
343 lock_minutes: self.passwords.lock_after.unwrap_or(10),370 lock_minutes: self.passwords.lock_after.unwrap_or(10),
344 lock_on_leave: self.passwords.lock_on_leave,371 lock_on_leave: self.passwords.lock_on_leave,
372 presence: self.live_options.presence,
373 picture: self.live_options.picture,
374 relay: self.live_options.relay.clone().unwrap_or_default(),
345 keyboard: crate::keys::Keyboard::new(),375 keyboard: crate::keys::Keyboard::new(),
346 });376 });
347 self.ui.open_popup(id());377 self.ui.open_popup(id());
...@@ -650,6 +680,14 @@ impl State {...@@ -650,6 +680,14 @@ impl State {
650 lock_on_leave: options.lock_on_leave,680 lock_on_leave: options.lock_on_leave,
651 };681 };
652 self.updates.set_automatic(options.automatic_updates);682 self.updates.set_automatic(options.automatic_updates);
683 let relay = options.relay.trim();
684 self.live_options = crate::settings::Live {
685 presence: options.presence,
686 picture: options.picture,
687 relay: (!relay.is_empty()).then(|| relay.to_owned()),
688 };
689 #[cfg(feature = "live")]
690 crate::live::configure(&self.author, &self.live_options);
653 self.show_spelling();691 self.show_spelling();
654 self.follow_color_scheme();692 self.follow_color_scheme();
655 self.install_keymap(options.keyboard.keymap);693 self.install_keymap(options.keyboard.keymap);
...@@ -662,6 +700,30 @@ impl State {...@@ -662,6 +700,30 @@ impl State {
662 }700 }
663}701}
664702
703#[cfg(feature = "live")]
704fn relay(state: &mut State, options: &mut Options) {
705 let ui = &mut state.ui;
706 let theme = ui.theme.clone();
707 let field = id().child("relay");
708 ui::text_field(
709 ui,
710 field,
711 &mut options.relay,
712 crate::live::DEFAULT_RELAY,
713 Spec {
714 size: [fill(), px(row_height(&theme))],
715 fill: Some(theme.base),
716 border: Some(theme.chip),
717 radius: 4.0,
718 pad: [6.0, 0.0],
719 ..Spec::default()
720 },
721 );
722 if let Some(node) = ui.access(field) {
723 node.set_label("Live Share relay");
724 }
725}
726
665fn section_id(section: &Section) -> Id {727fn section_id(section: &Section) -> Id {
666 list().child(section.name)728 list().child(section.name)
667}729}
crates/snowbound/src/settings.rs+29
...@@ -51,6 +51,29 @@ pub struct Settings {...@@ -51,6 +51,29 @@ pub struct Settings {
51 /// Pages shown lately, latest first, which the palette lists first and a launch returns to.51 /// Pages shown lately, latest first, which the palette lists first and a launch returns to.
52 pub recent: Vec<crate::navigation::Place>,52 pub recent: Vec<crate::navigation::Place>,
53 pub passwords: Passwords,53 pub passwords: Passwords,
54 pub live: Live,
55}
56
57/// Options' Live Share.
58#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
59#[serde(default)]
60pub struct Live {
61 /// Others with a shared notebook open see who you are, your page and your cursor.
62 pub presence: bool,
63 /// Your account's picture goes with your name.
64 pub picture: bool,
65 /// The relay computers on different networks meet through; none uses Snowbound's.
66 pub relay: Option<String>,
67}
68
69impl Default for Live {
70 fn default() -> Self {
71 Self {
72 presence: true,
73 picture: true,
74 relay: None,
75 }
76 }
54}77}
5578
56/// Options' Passwords, as OneNote 2010's Advanced page keeps them.79/// Options' Passwords, as OneNote 2010's Advanced page keeps them.
...@@ -194,6 +217,7 @@ impl crate::State {...@@ -194,6 +217,7 @@ impl crate::State {
194 keys: crate::commands::Keymap::current().saved(),217 keys: crate::commands::Keymap::current().saved(),
195 recent: self.trail.recent.clone(),218 recent: self.trail.recent.clone(),
196 passwords: self.passwords,219 passwords: self.passwords,
220 live: self.live_options.clone(),
197 };221 };
198 if let Err(error) = settings.save(path) {222 if let Err(error) = settings.save(path) {
199 eprintln!("Cannot save the settings in {}: {error}", path.display());223 eprintln!("Cannot save the settings in {}: {error}", path.display());
...@@ -278,6 +302,11 @@ mod tests {...@@ -278,6 +302,11 @@ mod tests {
278 lock_after: None,302 lock_after: None,
279 lock_on_leave: true,303 lock_on_leave: true,
280 },304 },
305 live: Live {
306 presence: false,
307 picture: false,
308 relay: Some("wss://relay.example.net".into()),
309 },
281 };310 };
282 settings.save(&path).unwrap();311 settings.save(&path).unwrap();
283 assert_eq!(Settings::load(&path), settings);312 assert_eq!(Settings::load(&path), settings);
crates/snowbound/src/share.rs created+503
...@@ -0,0 +1,503 @@
1//! Live Share's dialogs: sharing the notebook shown from this computer, with its code and the
2//! people connected, and opening a notebook another computer shares by its code.
3
4use crate::{Library, State};
5use accesskit::Role;
6use notebook::live::{
7 Relayed,
8 share::{self, Refusal, Sharing},
9 wire::Welcome,
10};
11use std::sync::{Arc, mpsc};
12use ui::{Anchor, Axis, Id, Spec, Ui, children, fill, fit, px};
13use winit::keyboard::NamedKey;
14
15const WIDTH: f32 = 420.0;
16/// What marks Live Share as still settling, wherever it is offered.
17pub(crate) const BETA: &str = "BETA";
18
19fn share_id() -> Id {
20 Id::ROOT.child("live-share")
21}
22
23fn join_id() -> Id {
24 Id::ROOT.child("open-shared")
25}
26
27fn password_field() -> Id {
28 share_id().child("password")
29}
30
31fn code_field() -> Id {
32 join_id().child("code")
33}
34
35fn join_password() -> Id {
36 join_id().child("password")
37}
38
39/// Live Share on one notebook while it is open.
40pub(crate) struct ShareDialog {
41 library: Arc<Library>,
42 protect: bool,
43 password: String,
44 copied: bool,
45}
46
47/// Open Shared Notebook while it is open.
48pub(crate) struct JoinDialog {
49 code: String,
50 password: String,
51 status: Status,
52 replies: crate::live::Channel<Result<Welcome, Refusal>>,
53}
54
55enum Status {
56 Idle,
57 Waiting,
58 Failed(String),
59}
60
61/// What to tell someone whose code didn't open a notebook.
62fn refusal(refusal: &Refusal) -> String {
63 match refusal {
64 Refusal::Malformed => "Enter the code as it was given, like 412-violet-otter.".into(),
65 Refusal::Wrong => "That code or password doesn’t open a notebook. Check it with the \
66 person sharing."
67 .into(),
68 Refusal::NoOne => "No one is sharing with that code. Check the number, and that \
69 Snowbound is open on their computer."
70 .into(),
71 Refusal::Expired => {
72 "That code expired after too many wrong tries. Ask for a new one.".into()
73 }
74 Refusal::TooMany(wait) => match wait.map(|wait| wait.as_secs().div_ceil(60)) {
75 Some(1) => "Too many wrong codes from this network. Try again in a minute.".into(),
76 Some(minutes) => {
77 format!("Too many wrong codes from this network. Try again in {minutes} minutes.")
78 }
79 None => "Too many wrong codes from this network. Try again later.".into(),
80 },
81 Refusal::Busy => "The Live Share relay is busy. Try again in a minute.".into(),
82 Refusal::Unreachable => {
83 "Can’t reach the Live Share relay. Check your internet connection.".into()
84 }
85 Refusal::TimedOut => "The computer sharing didn’t answer. Try again.".into(),
86 }
87}
88
89/// A dialog's frame, titled `title` with the BETA badge.
90fn frame(ui: &mut Ui, id: Id, title: &str) {
91 let theme = ui.theme.clone();
92 let row = theme.font_size * 2.0;
93 ui.open_as(
94 id,
95 Spec {
96 axis: Axis::Y,
97 size: [px(WIDTH), children()],
98 fill: Some(theme.popup),
99 border: Some(theme.chip),
100 shadow: Some(theme.shadow),
101 radius: 8.0,
102 pad: [16.0, 12.0],
103 gap: 6.0,
104 anchor: Some(Anchor::Dialog),
105 role: Some(Role::Dialog),
106 ..Spec::default()
107 },
108 );
109 if let Some(node) = ui.access(id) {
110 node.set_label(title);
111 }
112 ui.open(
113 "title",
114 Spec {
115 size: [fill(), px(row)],
116 gap: 6.0,
117 ..Spec::default()
118 },
119 );
120 ui.leaf(
121 "text",
122 Spec {
123 size: [fit(), px(row)],
124 text: Some(title),
125 bold: true,
126 role: Some(Role::Heading),
127 ..Spec::default()
128 },
129 );
130 ui::badge(ui, "beta", BETA, row);
131 ui.close();
132}
133
134/// Text wrapped across the dialog, dim where `dim`.
135fn text(ui: &mut Ui, part: &str, text: &str, dim: bool) {
136 let color = if dim {
137 ui.theme.text_dim
138 } else {
139 ui.theme.text
140 };
141 ui.leaf(
142 part,
143 Spec {
144 size: [fill(), fit()],
145 text: Some(text),
146 overflow: ui::Overflow::Wrap,
147 color: Some(color),
148 ..Spec::default()
149 },
150 );
151}
152
153fn status(ui: &mut Ui, message: &str) {
154 ui.leaf(
155 "status",
156 Spec {
157 size: [fill(), fit()],
158 text: Some(message),
159 overflow: ui::Overflow::Wrap,
160 pad: [0.0, 4.0],
161 role: Some(Role::Status),
162 ..Spec::default()
163 },
164 );
165}
166
167fn field_spec(ui: &Ui) -> Spec<'static> {
168 let theme = &ui.theme;
169 Spec {
170 size: [fill(), px(theme.font_size * 2.0)],
171 fill: Some(theme.base),
172 border: Some(theme.accent),
173 radius: 4.0,
174 pad: [6.0, 0.0],
175 ..Spec::default()
176 }
177}
178
179/// `label` before what `build` makes, in one row.
180fn labelled(ui: &mut Ui, label: &str, build: impl FnOnce(&mut Ui)) {
181 let row = ui.theme.font_size * 2.0;
182 ui.open(
183 label,
184 Spec {
185 size: [fill(), px(row)],
186 gap: 8.0,
187 ..Spec::default()
188 },
189 );
190 ui.leaf(
191 "label",
192 Spec {
193 size: [px(80.0), px(row)],
194 text: Some(label),
195 ..Spec::default()
196 },
197 );
198 build(ui);
199 ui.close();
200}
201
202/// Opens a row of buttons, a space taking what they leave.
203fn buttons(ui: &mut Ui) {
204 ui.open(
205 "buttons",
206 Spec {
207 size: [fill(), children()],
208 pad: [0.0, 8.0],
209 gap: 8.0,
210 ..Spec::default()
211 },
212 );
213 ui.leaf(
214 "space",
215 Spec {
216 size: [fill(), px(1.0)],
217 ..Spec::default()
218 },
219 );
220}
221
222impl State {
223 /// Opens Live Share on `library`.
224 pub(crate) fn open_live_share(&mut self, library: Arc<Library>) {
225 self.peers.share = Some(ShareDialog {
226 library,
227 protect: false,
228 password: String::new(),
229 copied: false,
230 });
231 self.ui.open_popup(share_id());
232 }
233
234 /// Opens Open Shared Notebook.
235 pub(crate) fn open_shared(&mut self) {
236 self.peers.join = Some(JoinDialog {
237 code: String::new(),
238 password: String::new(),
239 status: Status::Idle,
240 replies: mpsc::channel(),
241 });
242 self.ui.open_popup(join_id());
243 self.ui.focus_all(code_field());
244 }
245
246 pub(crate) fn live_dialogs(&mut self) {
247 self.share_dialog();
248 self.join_dialog();
249 }
250
251 fn share_dialog(&mut self) {
252 let Some(mut dialog) = self.peers.share.take() else {
253 return;
254 };
255 if !self.ui.popup_open(share_id()) {
256 return;
257 }
258 let location = dialog.library.location.clone();
259 let host = self.peers.hosts.get(&location).cloned();
260 let starting = self.peers.starting.get(&location).cloned();
261 let ui = &mut self.ui;
262 let theme = ui.theme.clone();
263 let fields: &[Id] = if host.is_none() && dialog.protect {
264 &[password_field()]
265 } else {
266 &[]
267 };
268 let entered =
269 ui::popup::navigation(ui, fields, &[NamedKey::Enter]).contains(&NamedKey::Enter);
270 frame(ui, share_id(), "Live Share");
271 text(ui, "notebook", &dialog.library.name, true);
272 let (mut start, mut stop, mut copy) = (false, false, None);
273 match (&host, &starting) {
274 (Some(host), _) => {
275 let code = host.code();
276 ui.open(
277 "code",
278 Spec {
279 size: [fill(), px(theme.font_size * 3.0)],
280 gap: 8.0,
281 ..Spec::default()
282 },
283 );
284 ui.leaf(
285 "text",
286 Spec {
287 size: [fill(), fill()],
288 text: Some(code.as_deref().unwrap_or("Getting a code…")),
289 font_size: Some(if code.is_some() {
290 theme.font_size * 1.6
291 } else {
292 theme.font_size
293 }),
294 bold: code.is_some(),
295 color: code.is_none().then_some(theme.text_dim),
296 role: Some(Role::Label),
297 ..Spec::default()
298 },
299 );
300 if let Some(code) = &code {
301 let label = if dialog.copied { "Copied" } else { "Copy" };
302 if ui::button(ui, "copy", label).clicked {
303 copy = Some(code.clone());
304 }
305 }
306 ui.close();
307 let protected = !host.sharing().password.is_empty();
308 text(
309 ui,
310 "how",
311 if protected {
312 "Others open it with Open Shared Notebook, this code and the password."
313 } else {
314 "Others open it with Open Shared Notebook and this code."
315 },
316 true,
317 );
318 match host.relayed() {
319 Relayed::Unreachable | Relayed::Refused(..) => status(
320 ui,
321 "Can’t reach the Live Share relay. Only people on this network can join.",
322 ),
323 _ => {}
324 }
325 ui.leaf(
326 "people",
327 Spec {
328 size: [fill(), px(theme.font_size * 2.0)],
329 text: Some("Connected"),
330 bold: true,
331 role: Some(Role::Heading),
332 ..Spec::default()
333 },
334 );
335 let guests = host.guests();
336 if guests.is_empty() {
337 text(ui, "nobody", "No one has joined yet.", true);
338 }
339 for guest in &guests {
340 ui.leaf(
341 ("guest", guest.hello.peer),
342 Spec {
343 size: [fill(), px(theme.font_size * 1.6)],
344 text: Some(&guest.hello.name),
345 role: Some(Role::ListItem),
346 ..Spec::default()
347 },
348 );
349 }
350 buttons(ui);
351 stop = ui::button(ui, "stop", "Stop Sharing").clicked;
352 }
353 (None, Some(None)) => {
354 text(ui, "starting", "Starting…", true);
355 buttons(ui);
356 }
357 (None, failed) => {
358 text(
359 ui,
360 "what",
361 "People you give the code to can open and edit this notebook while \
362 Snowbound is open on this computer.",
363 false,
364 );
365 if ui::check_box(ui, "protect", "Require a password", dialog.protect).clicked {
366 dialog.protect = !dialog.protect;
367 if dialog.protect {
368 ui.focus_all(password_field());
369 }
370 }
371 if dialog.protect {
372 labelled(ui, "Password:", |ui| {
373 let spec = field_spec(ui);
374 ui::password_field(ui, password_field(), &mut dialog.password, "", spec);
375 if let Some(node) = ui.access(password_field()) {
376 node.set_label("Password");
377 }
378 });
379 }
380 if let Some(Some(error)) = failed {
381 status(ui, &format!("Couldn’t start sharing: {error}"));
382 }
383 buttons(ui);
384 start = ui::button(ui, "start", "Start Sharing").clicked || entered;
385 }
386 }
387 let done = ui::button(ui, "done", "Done").clicked;
388 ui.close();
389 ui.close();
390 if let Some(code) = copy {
391 dialog.copied = self.clipboard.set_text(code).is_ok();
392 }
393 if stop {
394 self.stop_sharing(&location);
395 }
396 if start && !(dialog.protect && dialog.password.is_empty()) {
397 let password = if dialog.protect {
398 dialog.password.clone()
399 } else {
400 String::new()
401 };
402 match Sharing::new(&password) {
403 Ok(sharing) => self.start_sharing(&dialog.library, sharing),
404 Err(error) => {
405 self.peers
406 .starting
407 .insert(location, Some(error.to_string()));
408 }
409 }
410 }
411 if done {
412 self.ui.close_popup(share_id());
413 self.ui.set_focus(Some(crate::page()));
414 return;
415 }
416 self.peers.share = Some(dialog);
417 }
418
419 fn join_dialog(&mut self) {
420 let Some(mut dialog) = self.peers.join.take() else {
421 return;
422 };
423 if !self.ui.popup_open(join_id()) {
424 return;
425 }
426 let mut welcomed = None;
427 for reply in dialog.replies.1.try_iter() {
428 match reply {
429 Ok(welcome) => welcomed = Some(welcome),
430 Err(refused) => dialog.status = Status::Failed(refusal(&refused)),
431 }
432 }
433 if let Some(welcome) = welcomed {
434 match crate::live::joined(&self.cache, welcome) {
435 Ok(location) => {
436 self.ui.close_popup(join_id());
437 self.open_notebook(location, None);
438 return;
439 }
440 Err(error) => {
441 dialog.status = Status::Failed(format!("Couldn’t keep the code: {error}"));
442 }
443 }
444 }
445 let ui = &mut self.ui;
446 let entered =
447 ui::popup::navigation(ui, &[code_field(), join_password()], &[NamedKey::Enter])
448 .contains(&NamedKey::Enter);
449 frame(ui, join_id(), "Open Shared Notebook");
450 text(ui, "what", "Enter the code from the person sharing.", false);
451 labelled(ui, "Code:", |ui| {
452 let spec = field_spec(ui);
453 ui::text_field(ui, code_field(), &mut dialog.code, "412-violet-otter", spec);
454 if let Some(node) = ui.access(code_field()) {
455 node.set_label("Code");
456 }
457 });
458 labelled(ui, "Password:", |ui| {
459 let spec = field_spec(ui);
460 ui::password_field(
461 ui,
462 join_password(),
463 &mut dialog.password,
464 "If they set one",
465 spec,
466 );
467 if let Some(node) = ui.access(join_password()) {
468 node.set_label("Password");
469 }
470 });
471 match &dialog.status {
472 Status::Idle => {}
473 Status::Waiting => status(ui, "Connecting…"),
474 Status::Failed(message) => status(ui, message),
475 }
476 buttons(ui);
477 let cancel = ui::button(ui, "cancel", "Cancel").clicked;
478 let open = ui::button(ui, "open", "Open").clicked || entered;
479 ui.close();
480 ui.close();
481 if cancel {
482 self.ui.close_popup(join_id());
483 self.ui.set_focus(Some(crate::page()));
484 return;
485 }
486 if open && !matches!(dialog.status, Status::Waiting) {
487 match share::code(&dialog.code) {
488 None => dialog.status = Status::Failed(refusal(&Refusal::Malformed)),
489 Some(code) => {
490 dialog.status = Status::Waiting;
491 let (replies, redraw) = (dialog.replies.0.clone(), self.redraw.clone());
492 let password = dialog.password.clone();
493 crate::spawn(move || {
494 let reply = crate::live::join(&code, &password);
495 let _ = replies.send(reply);
496 redraw.wake();
497 });
498 }
499 }
500 }
501 self.peers.join = Some(dialog);
502 }
503}
crates/snowbound/src/sidebar.rs+7
...@@ -1258,6 +1258,13 @@ impl crate::State {...@@ -1258,6 +1258,13 @@ impl crate::State {
1258 "Open Notebook from Server…",1258 "Open Notebook from Server…",
1259 crate::Command::OpenFromServer(None),1259 crate::Command::OpenFromServer(None),
1260 ),1260 ),
1261 #[cfg(feature = "live")]
1262 (
1263 "shared",
1264 Leading::Icon(art::LINK),
1265 "Open Shared Notebook…",
1266 crate::Command::OpenShared,
1267 ),
1261 ]1268 ]
1262 .into_iter()1269 .into_iter()
1263 .chain(crate::guide::OFFERED.then_some((1270 .chain(crate::guide::OFFERED.then_some((
crates/snowbound/src/sync.rs+13
...@@ -265,6 +265,8 @@ struct Facts<'a> {...@@ -265,6 +265,8 @@ struct Facts<'a> {
265 changes_listed: bool,265 changes_listed: bool,
266 /// Whether the open section's file can be shown in the file manager.266 /// Whether the open section's file can be shown in the file manager.
267 local: bool,267 local: bool,
268 /// The computer sharing this notebook by Live Share stopped sharing it.
269 stopped: bool,
268}270}
269271
270/// What the reader picked in the popup.272/// What the reader picked in the popup.
...@@ -457,6 +459,9 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked {...@@ -457,6 +459,9 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked {
457 let conflict;459 let conflict;
458 // A problem's advice stays while working offline, so turning it on moves nothing.460 // A problem's advice stays while working offline, so turning it on moves nothing.
459 let advice = match state {461 let advice = match state {
462 SyncState::NotConnected if facts.stopped => Some(format!(
463 "The person sharing this notebook stopped sharing it. Your changes stay on {THIS}."
464 )),
460 SyncState::NotConnected => Some(match sync.queued {465 SyncState::NotConnected => Some(match sync.queued {
461 0 => format!("Can’t reach {host}. Sync continues when it’s back."),466 0 => format!("Can’t reach {host}. Sync continues when it’s back."),
462 _ => format!("Can’t reach {host}. {waiting} will sync when it’s back."),467 _ => format!("Can’t reach {host}. {waiting} will sync when it’s back."),
...@@ -1044,6 +1049,13 @@ impl State {...@@ -1044,6 +1049,13 @@ impl State {
1044 color: library.color(),1049 color: library.color(),
1045 location: &library.location,1050 location: &library.location,
1046 place: library.place(),1051 place: library.place(),
1052 #[cfg(feature = "live")]
1053 stopped: library
1054 .joined
1055 .as_ref()
1056 .is_some_and(|joined| joined.guest.stopped()),
1057 #[cfg(not(feature = "live"))]
1058 stopped: false,
1047 notice: library.notice.as_deref(),1059 notice: library.notice.as_deref(),
1048 sections: sections(&library, session),1060 sections: sections(&library, session),
1049 conflicts: session1061 conflicts: session
...@@ -1160,6 +1172,7 @@ mod tests {...@@ -1160,6 +1172,7 @@ mod tests {
1160 update: &IDLE,1172 update: &IDLE,
1161 changes_listed: false,1173 changes_listed: false,
1162 local: true,1174 local: true,
1175 stopped: false,
1163 }1176 }
1164 }1177 }
11651178
crates/ui/src/lib.rs+2-2
...@@ -15,8 +15,8 @@ mod widgets;...@@ -15,8 +15,8 @@ mod widgets;
15pub use list::{List, Row, Rows, list};15pub use list::{List, Row, Rows, list};
16pub use theme::{Menu, PopupMotion, Section, Shades, Shadow, Theme};16pub use theme::{Menu, PopupMotion, Section, Shades, Shadow, Theme};
17pub use widgets::{17pub use widgets::{
18 PaintedScroller, Scroller, ScrollerPart, Scrollers, button, check_box, edit_key,18 PaintedScroller, Scroller, ScrollerPart, Scrollers, badge, badge_width, button, check_box,
19 edit_modifiers, password_field, scrollbar, text_field,19 edit_key, edit_modifiers, password_field, scrollbar, text_field,
20};20};
2121
22use draw::{22use draw::{
crates/ui/src/popup.rs+48-13
...@@ -77,6 +77,8 @@ pub struct Item<'a> {...@@ -77,6 +77,8 @@ pub struct Item<'a> {
77 pub heading: bool,77 pub heading: bool,
78 /// Repeats an item listed further on, so shows only while unfiltered.78 /// Repeats an item listed further on, so shows only while unfiltered.
79 pub repeated: bool,79 pub repeated: bool,
80 /// A tag after the text, as `badge` draws one.
81 pub badge: Option<&'a str>,
80}82}
8183
82impl Item<'_> {84impl Item<'_> {
...@@ -107,20 +109,31 @@ pub fn menu(...@@ -107,20 +109,31 @@ pub fn menu(
107 return None;109 return None;
108 }110 }
109 let style = ui.theme.menu();111 let style = ui.theme.menu();
112 let badges: Vec<f32> = (items.iter())
113 .map(|item| {
114 item.badge
115 .map_or(0.0, |badge| crate::badge_width(ui, badge) + ICON_GAP)
116 })
117 .collect();
110 let mut measure = |text| ui.texts.label(text, style.font_size, ui.frame).size[0];118 let mut measure = |text| ui.texts.label(text, style.font_size, ui.frame).size[0];
111 let [text, shortcut] = items.iter().fold([0.0_f32; 2], |[text, shortcut], item| {119 let [text, shortcut] =
112 [120 items
113 text.max(121 .iter()
114 measure(item.text)122 .zip(&badges)
115 + if item.highlight.is_some() {123 .fold([0.0_f32; 2], |[text, shortcut], (item, badge)| {
116 2.0 * SAMPLE_PAD124 [
117 } else {125 text.max(
118 0.0126 measure(item.text)
119 },127 + badge
120 ),128 + if item.highlight.is_some() {
121 shortcut.max(measure(item.trailing())),129 2.0 * SAMPLE_PAD
122 ]130 } else {
123 });131 0.0
132 },
133 ),
134 shortcut.max(measure(item.trailing())),
135 ]
136 });
124 let width =137 let width =
125 text + if shortcut > 0.0 {138 text + if shortcut > 0.0 {
126 3.0 * ICON_GAP + shortcut139 3.0 * ICON_GAP + shortcut
...@@ -575,6 +588,9 @@ fn menu_row(ui: &mut Ui, style: &Menu, matches: &Matches, owner: Role, row: Row)...@@ -575,6 +588,9 @@ fn menu_row(ui: &mut Ui, style: &Menu, matches: &Matches, owner: Role, row: Row)
575 _ => Role::MenuItem,588 _ => Role::MenuItem,
576 });589 });
577 node.set_label(item.text);590 node.set_label(item.text);
591 if let Some(badge) = item.badge {
592 node.set_description(badge);
593 }
578 if let Some(checked) = item.checked {594 if let Some(checked) = item.checked {
579 node.set_toggled(checked.into());595 node.set_toggled(checked.into());
580 }596 }
...@@ -686,6 +702,25 @@ fn menu_row(ui: &mut Ui, style: &Menu, matches: &Matches, owner: Role, row: Row)...@@ -686,6 +702,25 @@ fn menu_row(ui: &mut Ui, style: &Menu, matches: &Matches, owner: Role, row: Row)
686 },702 },
687 );703 );
688 }704 }
705 None if item.badge.is_some() => {
706 ui.leaf(
707 "text",
708 Spec {
709 size: [fit(), fill()],
710 ..text
711 },
712 );
713 if let Some(badge) = item.badge {
714 crate::badge(ui, "badge", badge, style.row);
715 }
716 ui.leaf(
717 "rest",
718 Spec {
719 size: [fill(), fill()],
720 ..Spec::default()
721 },
722 );
723 }
689 None => {724 None => {
690 ui.leaf("text", text);725 ui.leaf("text", text);
691 }726 }
crates/ui/src/widgets.rs+36
...@@ -32,6 +32,42 @@ pub fn button(ui: &mut Ui, part: impl Hash, text: &str) -> Signal {...@@ -32,6 +32,42 @@ pub fn button(ui: &mut Ui, part: impl Hash, text: &str) -> Signal {
32 ui.leaf(part, spec)32 ui.leaf(part, spec)
33}33}
3434
35/// A short tag in the accent's colour, centred in a box `height` tall, as BETA marks a
36/// feature still settling beside its name.
37pub fn badge(ui: &mut Ui, part: impl Hash, text: &str, height: f32) {
38 let theme = &ui.theme;
39 let [red, green, blue, _] = theme.accent;
40 let tall = (theme.font_size * 1.15).min(height).round();
41 let spec = Spec {
42 size: [fit(), px(tall)],
43 text: Some(text),
44 font_size: Some((theme.font_size * 0.7).round()),
45 bold: true,
46 color: Some(theme.accent),
47 fill: Some([red, green, blue, 0.16]),
48 radius: 3.0,
49 pad: [4.0, 0.0],
50 center: true,
51 ..Spec::default()
52 };
53 ui.open(
54 part,
55 Spec {
56 size: [crate::children(), px(height)],
57 pad: [0.0, ((height - tall) / 2.0).max(0.0)],
58 ..Spec::default()
59 },
60 );
61 ui.leaf("text", spec);
62 ui.close();
63}
64
65/// The width `badge` takes for `text`.
66pub fn badge_width(ui: &mut Ui, text: &str) -> f32 {
67 let size = (ui.theme.font_size * 0.7).round();
68 ui.texts.label(text, size, ui.frame).size[0] + 8.0
69}
70
35/// A check box before `label`, `checked` or not; a click on either reports for the caller71/// A check box before `label`, `checked` or not; a click on either reports for the caller
36/// to toggle.72/// to toggle.
37pub fn check_box(ui: &mut Ui, part: impl Hash, label: &str, checked: bool) -> Signal {73pub fn check_box(ui: &mut Ui, part: impl Hash, label: &str, checked: bool) -> Signal {
tools/canvas/build_macos.py+3-1
...@@ -146,7 +146,9 @@ if build:...@@ -146,7 +146,9 @@ if build:
146 'NSPrincipalClass': 'NSApplication',146 'NSPrincipalClass': 'NSApplication',
147 'CFBundleIconFile': 'Snowbound',147 'CFBundleIconFile': 'Snowbound',
148 'CFBundleIconName': 'Snowbound-Tahoe',148 'CFBundleIconName': 'Snowbound-Tahoe',
149 'NSLocalNetworkUsageDescription': 'Snowbound connects to file servers on your network to open and sync shared notebooks.',149 'NSLocalNetworkUsageDescription': 'Snowbound connects to file servers and other Snowbounds on your network to open and sync shared notebooks.',
150 # Live presence and Live Share find other Snowbounds by Bonjour.
151 'NSBonjourServices': ['_snowbound._tcp'],
150 'NSMicrophoneUsageDescription':'Snowbound records audio into your notes when you choose Record Audio or Record Video.',152 'NSMicrophoneUsageDescription':'Snowbound records audio into your notes when you choose Record Audio or Record Video.',
151 'NSCameraUsageDescription': 'Snowbound records video into your notes when you choose Record Video.',153 'NSCameraUsageDescription': 'Snowbound records video into your notes when you choose Record Video.',
152 'NSUbiquitousContainers': {CONTAINER: {154 'NSUbiquitousContainers': {CONTAINER: {