| author | |
| committer | |
| log | 3d84683db457cfb9c19b314a937cbce6b1b8091d |
| tree | 760220ced5c5d092c9b438c868459165b543666a |
| parent | e01c7cf1781d73fb57bc66e80bb1b3da9cafa9d2 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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.516 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) |
| 312 | every device ends on the same file with each typed string once. OneNote 2010 cold-opens a | 312 | every device ends on the same file with each typed string once. OneNote 2010 cold-opens a |
| 313 | merged section with its conflict page as its own (`corpus/icloud-merge`). | 313 | merged section with its conflict page as its own (`corpus/icloud-merge`). |
| 314 | 314 | ||
| 315 | ## Live Share | ||
| 316 | |||
| 317 | Live Share (`notebook::live::share`, beta) opens a notebook one Snowbound holds on others, | ||
| 318 | with no service holding the notebook. The host serves its notebook's storage verbs to the peers | ||
| 319 | in the share's room; a guest runs the replica, queue and merge it runs on an SMB share against | ||
| 320 | them (`Notebook::open_hosted`, `Section::resume_hosted`, `Background::hosted`), so offline | ||
| 321 | queueing, rebases and conflict pages behave as on a share, and the host's files are only ever | ||
| 322 | written by its own storage, OneNote's locks included. A guest meets the host first in the | ||
| 323 | room of a short code (`412-violet-otter`, SPAKE2 on its words and any password) through | ||
| 324 | Snowbound's relay or by mDNS, and is welcomed with the share's room and its random secret; | ||
| 325 | stopping or restarting a share retires the secret. A guest's commit is checked on the host's | ||
| 326 | image before it is committed, and a guest can name nothing outside the notebook. Large reads | ||
| 327 | and uploads travel 128 KiB at a time, at most 512 KiB unanswered, so a relay never holds much | ||
| 328 | for a slow peer. A host that goes away leaves its guests working offline: the notebook opens | ||
| 329 | from its last listing and edits publish when it is back. The relay is | ||
| 330 | `crates/relay`. | ||
| 331 | |||
| 332 | Live presence, on by default, shows who else has a notebook others reach too (on a server, in | ||
| 333 | iCloud Drive, in another computer's folder, or by Live Share): their avatars, page and caret. | ||
| 334 | Its room's secret is a random one in `.snowbound/live.json`, which Live Share never serves. | ||
| 335 | |||
| 315 | ## Notebook structure | 336 | ## Notebook structure |
| 316 | 337 | ||
| 317 | Sections, groups and the notebook's own colour live in the `.onetoc2` files, | 338 | Sections, 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 |
| 329 | plain files, not revision stores: pictures named by their content, and small JSON | 350 | plain files, not revision stores: pictures named by their content, and small JSON |
| 330 | mappings that every writer merges before replacing. The first is tag art: a tag keeps | 351 | mappings that every writer merges before replacing. The first is tag art: a tag keeps |
| 331 | OneNote's definition and a fallback symbol on the page, and `tags.json` maps its name and | 352 | OneNote's definition and a fallback symbol on the page, and `tags.json` maps its name and |
| 332 | symbol to a picture that Snowbound draws in the symbol's place. | 353 | symbol to a picture that Snowbound draws in the symbol's place. `live.json` holds the |
| 354 | notebook's presence room secret. | ||
| 333 | 355 | ||
| 334 | ## Recovery and migration | 356 | ## Recovery and migration |
| 335 | 357 |
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::Help | 1165 | | 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 | } |
| 40 | 40 | ||
| 41 | fn notify_background() { | 41 | pub(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 | } |
| 46 | 46 | ||
| 47 | /// What the location of a notebook another computer shares by Live Share starts with. | ||
| 48 | pub 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. |
| 48 | const TIMEOUT: Duration = Duration::from_secs(10); | 51 | const 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 | } |
| 329 | 335 | ||
| 330 | /// Keys of password-protected sections, by file identity, with when each section was last | 336 | /// 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 | } |
| 353 | 361 | ||
| ... | @@ -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 with | 364 | /// 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 | } |
| 406 | 417 | ||
| 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 { |
| 643 | 695 | ||
| 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 opened | 927 | /// 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 | } |
| 861 | 933 | ||
| ... | @@ -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, iCloud | 944 | /// 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 self | 951 | let mut place: Vec<String> = match self |
| 876 | .server | 952 | .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` or | 1 | //! 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 others | 2 | //! 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 each | 3 | //! 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 through | 4 | //! 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 |
| 7 | 7 | //! (`Joined`), as `share.rs`'s dialogs start them. | |
| 8 | use crate::{Command, Session, State, TAB_ROW, page, platform}; | 8 | |
| 9 | use crate::{Command, Library, Session, State, TAB_ROW, page, platform, settings}; | ||
| 9 | use canvas::{document::TextPosition, editor::TextOutline}; | 10 | use canvas::{document::TextPosition, editor::TextOutline}; |
| 10 | use notebook::live::{self, Caret, Hello, Peer, Reach, Room, Spot}; | 11 | use notebook::live::{ |
| 12 | self, Caret, Hello, Peer, Reach, Room, Spot, | ||
| 13 | share::{Guest, Host, Sharing}, | ||
| 14 | }; | ||
| 15 | use notebook::session::{Background, Notebook}; | ||
| 11 | use onestore::ExGuid; | 16 | use onestore::ExGuid; |
| 12 | use std::{collections::HashMap, sync::OnceLock}; | 17 | use std::{ |
| 18 | collections::{BTreeMap, HashMap}, | ||
| 19 | io, | ||
| 20 | path::{Path, PathBuf}, | ||
| 21 | sync::{Arc, Mutex, OnceLock, Weak, mpsc}, | ||
| 22 | }; | ||
| 13 | use ui::{Flags, Spec, children, fill, fit, px}; | 23 | use ui::{Flags, Spec, children, fill, fit, px}; |
| 14 | 24 | ||
| 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; |
| 17 | const AVATAR: f32 = 22.0; | 27 | const AVATAR: f32 = 22.0; |
| 18 | /// The name flag above a caret. | 28 | /// The name flag above a caret. |
| 19 | const FLAG: f32 = 15.0; | 29 | const FLAG: f32 = 15.0; |
| 30 | /// The relay used where Options names none. | ||
| 31 | pub(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. | ||
| 33 | const FIRST_LISTING: std::time::Duration = std::time::Duration::from_secs(15); | ||
| 20 | 34 | ||
| 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. |
| 22 | pub(crate) struct Peers { | 36 | struct 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 | |
| 42 | static ME: Mutex<Option<Me>> = Mutex::new(None); | ||
| 43 | |||
| 44 | /// Takes Options' name, picture and relay for every meeting from now on. | ||
| 45 | pub(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. | ||
| 58 | fn 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 | } |
| 29 | 68 | ||
| 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), |
| 31 | const DEFAULT_RELAY: &str = "wss://relay.snowbound.paperclover.net"; | 70 | /// else Options', else Snowbound's. |
| 71 | fn 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 | } | ||
| 32 | 84 | ||
| 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. | ||
| 33 | fn reach() -> Option<Reach> { | 87 | fn 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 | } |
| 40 | 94 | ||
| 95 | /// Where this computer keeps what it shares and what it joined: beside the replicas, which | ||
| 96 | /// hold the same notebooks' contents. | ||
| 97 | fn kept(cache: &Path, name: &str) -> PathBuf { | ||
| 98 | cache.join("live").join(name) | ||
| 99 | } | ||
| 100 | |||
| 101 | fn 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. | ||
| 109 | fn 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)] | ||
| 126 | struct 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 | |||
| 135 | const JOINED: &str = "joined.json"; | ||
| 136 | const HOSTING: &str = "hosting.json"; | ||
| 137 | |||
| 138 | /// A notebook another computer shares, open here: the way to it and its names. | ||
| 139 | pub 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 | |||
| 147 | impl 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. | ||
| 214 | pub(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. | ||
| 223 | pub(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. | ||
| 242 | pub(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)] | ||
| 247 | pub(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 | |||
| 41 | impl State { | 270 | impl 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(_) => self | 286 | _ => 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 | } |
| 87 | 490 | ||
| 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 | } |
| 108 | 511 | ||
| 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.peers | 515 | 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 | } |
| 116 | 539 | ||
| 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 | peers | 580 | .entry(hello.peer) |
| 158 | .pictures | 581 | .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 | } |
| 214 | 686 | ||
| 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 | } |
| 331 | 803 | ||
| 804 | /// Whether others may have `library` open too: on a server, in iCloud Drive, or in a folder | ||
| 805 | /// another computer holds. | ||
| 806 | fn 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. |
| 333 | fn section(session: &Session) -> Option<[u8; 16]> { | 815 | fn section(session: &Session) -> Option<[u8; 16]> { |
| 334 | session | 816 | session |
crates/snowbound/src/main.rs+22-2| ... | @@ -68,6 +68,8 @@ mod screenshot; | ... | @@ -68,6 +68,8 @@ mod screenshot; |
| 68 | mod search; | 68 | mod search; |
| 69 | mod server; | 69 | mod server; |
| 70 | mod settings; | 70 | mod settings; |
| 71 | #[cfg(feature = "live")] | ||
| 72 | mod share; | ||
| 71 | mod sidebar; | 73 | mod 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, by | 832 | /// 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 | } |
| 126 | 129 | ||
| 127 | /// OneNote 2010's section and notebook colours, COLORREF, with their names in its Section | 130 | /// 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 | None | 739 | 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 | None | 749 | 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 | } |
| 251 | 278 | ||
| ... | @@ -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 | } |
| 664 | 702 | ||
| 703 | #[cfg(feature = "live")] | ||
| 704 | fn 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 | |||
| 665 | fn section_id(section: &Section) -> Id { | 727 | fn 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)] | ||
| 60 | pub 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 | |||
| 69 | impl Default for Live { | ||
| 70 | fn default() -> Self { | ||
| 71 | Self { | ||
| 72 | presence: true, | ||
| 73 | picture: true, | ||
| 74 | relay: None, | ||
| 75 | } | ||
| 76 | } | ||
| 54 | } | 77 | } |
| 55 | 78 | ||
| 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 | |||
| 4 | use crate::{Library, State}; | ||
| 5 | use accesskit::Role; | ||
| 6 | use notebook::live::{ | ||
| 7 | Relayed, | ||
| 8 | share::{self, Refusal, Sharing}, | ||
| 9 | wire::Welcome, | ||
| 10 | }; | ||
| 11 | use std::sync::{Arc, mpsc}; | ||
| 12 | use ui::{Anchor, Axis, Id, Spec, Ui, children, fill, fit, px}; | ||
| 13 | use winit::keyboard::NamedKey; | ||
| 14 | |||
| 15 | const WIDTH: f32 = 420.0; | ||
| 16 | /// What marks Live Share as still settling, wherever it is offered. | ||
| 17 | pub(crate) const BETA: &str = "BETA"; | ||
| 18 | |||
| 19 | fn share_id() -> Id { | ||
| 20 | Id::ROOT.child("live-share") | ||
| 21 | } | ||
| 22 | |||
| 23 | fn join_id() -> Id { | ||
| 24 | Id::ROOT.child("open-shared") | ||
| 25 | } | ||
| 26 | |||
| 27 | fn password_field() -> Id { | ||
| 28 | share_id().child("password") | ||
| 29 | } | ||
| 30 | |||
| 31 | fn code_field() -> Id { | ||
| 32 | join_id().child("code") | ||
| 33 | } | ||
| 34 | |||
| 35 | fn join_password() -> Id { | ||
| 36 | join_id().child("password") | ||
| 37 | } | ||
| 38 | |||
| 39 | /// Live Share on one notebook while it is open. | ||
| 40 | pub(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. | ||
| 48 | pub(crate) struct JoinDialog { | ||
| 49 | code: String, | ||
| 50 | password: String, | ||
| 51 | status: Status, | ||
| 52 | replies: crate::live::Channel<Result<Welcome, Refusal>>, | ||
| 53 | } | ||
| 54 | |||
| 55 | enum Status { | ||
| 56 | Idle, | ||
| 57 | Waiting, | ||
| 58 | Failed(String), | ||
| 59 | } | ||
| 60 | |||
| 61 | /// What to tell someone whose code didn't open a notebook. | ||
| 62 | fn 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. | ||
| 90 | fn 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`. | ||
| 135 | fn 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 | |||
| 153 | fn 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 | |||
| 167 | fn 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. | ||
| 180 | fn 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. | ||
| 203 | fn 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 | |||
| 222 | impl 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 | } |
| 269 | 271 | ||
| 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: session | 1061 | 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 | } |
| 1165 | 1178 |
crates/ui/src/lib.rs+2-2| ... | @@ -15,8 +15,8 @@ mod widgets; | ... | @@ -15,8 +15,8 @@ mod widgets; |
| 15 | pub use list::{List, Row, Rows, list}; | 15 | pub use list::{List, Row, Rows, list}; |
| 16 | pub use theme::{Menu, PopupMotion, Section, Shades, Shadow, Theme}; | 16 | pub use theme::{Menu, PopupMotion, Section, Shades, Shadow, Theme}; |
| 17 | pub use widgets::{ | 17 | pub 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 | }; |
| 21 | 21 | ||
| 22 | use draw::{ | 22 | use 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 | } |
| 81 | 83 | ||
| 82 | impl Item<'_> { | 84 | impl 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_PAD | 124 | [ |
| 117 | } else { | 125 | text.max( |
| 118 | 0.0 | 126 | 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 + shortcut | 139 | 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 | } |
| 34 | 34 | ||
| 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. | ||
| 37 | pub 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`. | ||
| 66 | pub 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 caller | 71 | /// A check box before `label`, `checked` or not; a click on either reports for the caller |
| 36 | /// to toggle. | 72 | /// to toggle. |
| 37 | pub fn check_box(ui: &mut Ui, part: impl Hash, label: &str, checked: bool) -> Signal { | 73 | pub 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: { |