diff --git a/arc/sync.md b/arc/sync.md index 1cd5b296b6e85c476fee6cc313b15357623208d0..f6d65116a65292cb1e32c84c7f56cd554ea278ad 100644 --- a/arc/sync.md +++ b/arc/sync.md @@ -312,6 +312,27 @@ compare-and-swap, either side of a race winning, late deliveries, offline spans) every device ends on the same file with each typed string once. OneNote 2010 cold-opens a merged section with its conflict page as its own (`corpus/icloud-merge`). +## Live Share + +Live Share (`notebook::live::share`, beta) opens a notebook one Snowbound holds on others, +with no service holding the notebook. The host serves its notebook's storage verbs to the peers +in the share's room; a guest runs the replica, queue and merge it runs on an SMB share against +them (`Notebook::open_hosted`, `Section::resume_hosted`, `Background::hosted`), so offline +queueing, rebases and conflict pages behave as on a share, and the host's files are only ever +written by its own storage, OneNote's locks included. A guest meets the host first in the +room of a short code (`412-violet-otter`, SPAKE2 on its words and any password) through +Snowbound's relay or by mDNS, and is welcomed with the share's room and its random secret; +stopping or restarting a share retires the secret. A guest's commit is checked on the host's +image before it is committed, and a guest can name nothing outside the notebook. Large reads +and uploads travel 128 KiB at a time, at most 512 KiB unanswered, so a relay never holds much +for a slow peer. A host that goes away leaves its guests working offline: the notebook opens +from its last listing and edits publish when it is back. The relay is +`crates/relay`. + +Live presence, on by default, shows who else has a notebook others reach too (on a server, in +iCloud Drive, in another computer's folder, or by Live Share): their avatars, page and caret. +Its room's secret is a random one in `.snowbound/live.json`, which Live Share never serves. + ## Notebook structure 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 plain files, not revision stores: pictures named by their content, and small JSON mappings that every writer merges before replacing. The first is tag art: a tag keeps OneNote's definition and a fallback symbol on the page, and `tags.json` maps its name and -symbol to a picture that Snowbound draws in the symbol's place. +symbol to a picture that Snowbound draws in the symbol's place. `live.json` holds the +notebook's presence room secret. ## Recovery and migration diff --git a/crates/snowbound/src/commands.rs b/crates/snowbound/src/commands.rs index b50e8ed72ceab8e7763aa8c3f0f42028e076ef32..8a182a62e6bb83c2b6601c0839d7796a9e1a67c8 100644 --- a/crates/snowbound/src/commands.rs +++ b/crates/snowbound/src/commands.rs @@ -19,6 +19,10 @@ pub enum Id { NewNotebook, OpenNotebook, OpenFromServer, + /// Open Shared Notebook: a notebook another computer shares by Live Share. + OpenShared, + /// Live Share on the notebook shown. + LiveShare, CloseNotebook, NewSection, NewSectionGroup, @@ -255,6 +259,8 @@ pub const COMMANDS: &[Command] = &[ row!(Id::NewNotebook, "New Notebook…", NONE, NONE), row!(Id::OpenNotebook, "Open Notebook…", &[cmd('o')], &[cmd('o')]), row!(Id::OpenFromServer, "Open Notebook from Server…", NONE, NONE), + row!(Id::OpenShared, "Open Shared Notebook…", NONE, NONE), + row!(Id::LiveShare, "Live Share…", NONE, NONE), row!(Id::CloseNotebook, "Close This Notebook", NONE, NONE), row!(Id::NewSection, "New Section", &[cmd('t')], &[cmd('t')]), row!(Id::NewSectionGroup, "New Section Group", NONE, NONE), @@ -1159,6 +1165,16 @@ impl State { | Id::Help | Id::CheckForUpdates => enabled(!modal), Id::CloseNotebook => enabled(!modal && self.notebook().is_some()), + Id::OpenShared => enabled(!modal && cfg!(feature = "live")), + #[cfg(feature = "live")] + Id::LiveShare => enabled( + !modal + && self.notebook().is_some_and(|library| { + library.catalog().is_some() && library.joined.is_none() + }), + ), + #[cfg(not(feature = "live"))] + Id::LiveShare => enabled(false), Id::PasswordProtect => enabled(!modal && self.shown_section().is_some()), Id::SaveAs => enabled(!modal && self.notebook().is_some()), Id::RecycleBin => Status { @@ -1452,6 +1468,19 @@ impl State { Id::NewNotebook => Work::NewNotebook, Id::OpenNotebook => Work::OpenNotebook, Id::OpenFromServer => Work::OpenFromServer(None), + #[cfg(feature = "live")] + Id::OpenShared => { + self.open_shared(); + return Ok(()); + } + #[cfg(feature = "live")] + Id::LiveShare => { + let library = Arc::clone(self.notebook().ok_or("No notebook is open")?); + self.open_live_share(library); + return Ok(()); + } + #[cfg(not(feature = "live"))] + Id::OpenShared | Id::LiveShare => return Ok(()), Id::CloseNotebook => { Work::CloseNotebook(Arc::clone(self.notebook().ok_or("No notebook is open")?)) } diff --git a/crates/snowbound/src/library.rs b/crates/snowbound/src/library.rs index a30f2453931d09468ee97de748b85df880c0aadb..910a8c0b1d6f2534eaf1319b98997d68991d67f6 100644 --- a/crates/snowbound/src/library.rs +++ b/crates/snowbound/src/library.rs @@ -38,12 +38,15 @@ pub fn on_background(notify: impl Fn() + Send + Sync + 'static) { let _ = NOTIFY.set(Box::new(notify)); } -fn notify_background() { +pub(crate) fn notify_background() { if let Some(notify) = NOTIFY.get() { notify(); } } +/// What the location of a notebook another computer shares by Live Share starts with. +pub const SHARED: &str = "live://"; + /// How long an SMB request may take before the share counts as unreachable. const TIMEOUT: Duration = Duration::from_secs(10); /// The largest section file read whole over SMB. @@ -325,6 +328,9 @@ pub struct Library { themes: Mutex)>>, /// The keys of its password-protected sections unlocked this run, kept as it is read again. keys: Arc, + /// The share a notebook another computer shares by Live Share is reached through. + #[cfg(feature = "live")] + pub joined: Option>, } /// Keys of password-protected sections, by file identity, with when each section was last @@ -348,6 +354,8 @@ impl Library { kept: Mutex::new(crate::prefetch::Recent::new(KEPT)), themes: Default::default(), keys: Default::default(), + #[cfg(feature = "live")] + joined: None, } } @@ -356,6 +364,9 @@ impl Library { /// account the system keeps for the mount, as OneNote's own client coordinates with /// OneNote; without that account it opens through the mount. pub fn notebook(location: &str, cache: &Path) -> Self { + if location.starts_with(SHARED) { + return Self::joined(location, cache); + } if let Some(mount) = server_address(location) { let login = match mount.user { Some(_) => crate::platform::smb_login(&mount), @@ -404,6 +415,47 @@ impl Library { } } + /// The notebook another computer shares at `location` by Live Share, as its last listing + /// has it until that computer answers. + #[cfg(feature = "live")] + fn joined(location: &str, cache: &Path) -> Self { + let mut library = Self::new(location, String::new(), cache); + match crate::live::Joined::open(location, cache) { + Ok((joined, notebook)) => { + let background = Background::hosted(Arc::clone(&joined.guest), notify_background) + .inspect_err(|error| eprintln!("Background sync did not start: {error}")) + .ok() + .map(|background| { + background.set_offline(offline()); + Arc::new(background) + }); + let mut notebook = notebook; + if let Some(background) = &background { + background.watch(notebook.replicas()); + joined.follow(background); + } + library.name = display_name(cache, location).unwrap_or_else(|| joined.name.clone()); + library.tag_art = Mutex::new(Arc::new(read_tag_art(¬ebook))); + library.notebook = Ok(Some(notebook)); + library.background = background; + library.joined = Some(Arc::new(joined)); + } + Err((name, reason)) => { + library.name = display_name(cache, location).unwrap_or(name); + library.notebook = Err(reason); + } + } + library + } + + #[cfg(not(feature = "live"))] + fn joined(location: &str, cache: &Path) -> Self { + Self { + notebook: Err("This version of Snowbound can’t open shared notebooks.".into()), + ..Self::new(location, "Shared notebook".into(), cache) + } + } + /// No server is in reach of the browser until a relay carries SMB to it. #[cfg(target_arch = "wasm32")] pub fn on_share(_: &str, _: Mount, _: Login, _: &Path) -> Result { @@ -643,6 +695,13 @@ impl Library { /// This notebook read again, the way it was opened, for changing its structure. pub fn reopen(&self) -> Result> { + #[cfg(feature = "live")] + if let Some(joined) = &self.joined { + return Ok(Notebook::open_hosted( + Arc::clone(&joined.guest), + &self.cache, + )?); + } Ok(match &self.server { #[cfg(not(target_arch = "wasm32"))] Some(server) => { @@ -678,6 +737,8 @@ impl Library { watch, tag_art: Mutex::new(self.tag_art()), keys: Arc::clone(&self.keys), + #[cfg(feature = "live")] + joined: self.joined.clone(), ..Self::new(&self.location, name, &self.cache) } } @@ -745,6 +806,18 @@ impl Library { return Ok(kept); } let opened = match (&self.notebook, &self.server) { + #[cfg(feature = "live")] + (Ok(Some(notebook)), _) if let Some(joined) = &self.joined => { + let cache = notebook.replica_path(path)?; + notebook::fs::create_dir_all(cache.parent().unwrap_or(&self.cache))?; + notebook::Replica::open_or_create(&cache, key.as_ref(), || { + notebook.read_section(path) + }) + .and_then(|replica| { + let guest = Arc::clone(&joined.guest); + Section::resume_hosted(path.to_owned(), replica, guest, notifier()) + }) + } #[cfg(target_arch = "wasm32")] (Ok(Some(_)), Some(_)) => unreachable!("The browser opens no notebook on a server"), #[cfg(not(target_arch = "wasm32"))] @@ -854,8 +927,7 @@ impl Library { /// The notebook's folder or section file on this computer; none for a notebook opened /// straight from its server. pub fn folder(&self) -> Option<&Path> { - server_address(&self.location) - .is_none() + (server_address(&self.location).is_none() && !self.location.starts_with(SHARED)) .then(|| Path::new(&self.location)) } @@ -872,6 +944,10 @@ impl Library { /// Where the notebook lives, as its reader knows the place: the server and share, iCloud /// Drive, a drive or this computer, then each folder down to the notebook's, left out. pub fn place(&self) -> Vec { + #[cfg(feature = "live")] + if let Some(joined) = &self.joined { + return vec![format!("{}’s computer", joined.host)]; + } let mut place: Vec = match self .server .as_ref() diff --git a/crates/snowbound/src/live.rs b/crates/snowbound/src/live.rs index d186b43f1a9aeab9e6c2b1f8c46d10093ea48b51..1d573b7e7a071e0cb4a3c905293e506ffbea17a0 100644 --- a/crates/snowbound/src/live.rs +++ b/crates/snowbound/src/live.rs @@ -1,15 +1,25 @@ -//! Live presence in the window (feature `live`, on where `SNOWBOUND_LIVE` is `network` or -//! `loopback`, through the default relay unless `SNOWBOUND_LIVE_RELAY` names another or `off`): the others -//! with the notebook open as avatars beside the search box, a dot on the tab of the page each -//! has open, and their carets on the page, each in a colour of their own. They meet through -//! the notebook's identity, or through `SNOWBOUND_LIVE_CODE` where it is set; through a relay, -//! its words alone ask the relay to number it (`resources/live-share.md`). +//! Live presence and Live Share in the window (feature `live`, `resources/live-share.md`). +//! In a notebook others reach too (on a server, in iCloud Drive, on another computer, or by +//! Live Share), whoever else has it open shows as an avatar beside the search box, a dot on +//! the tab of their page and their caret on the page, each in a colour of their own; they see +//! this window the same way unless Options turns that off. Live Share serves a notebook on +//! this computer to whoever types its code (`Host`), or opens one another computer shares +//! (`Joined`), as `share.rs`'s dialogs start them. -use crate::{Command, Session, State, TAB_ROW, page, platform}; +use crate::{Command, Library, Session, State, TAB_ROW, page, platform, settings}; use canvas::{document::TextPosition, editor::TextOutline}; -use notebook::live::{self, Caret, Hello, Peer, Reach, Room, Spot}; +use notebook::live::{ + self, Caret, Hello, Peer, Reach, Room, Spot, + share::{Guest, Host, Sharing}, +}; +use notebook::session::{Background, Notebook}; use onestore::ExGuid; -use std::{collections::HashMap, sync::OnceLock}; +use std::{ + collections::{BTreeMap, HashMap}, + io, + path::{Path, PathBuf}, + sync::{Arc, Mutex, OnceLock, Weak, mpsc}, +}; use ui::{Flags, Spec, children, fill, fit, px}; /// The side of the picture sent, in pixels. @@ -17,74 +27,467 @@ const PICTURE: u32 = 96; const AVATAR: f32 = 22.0; /// The name flag above a caret. const FLAG: f32 = 15.0; +/// The relay used where Options names none. +pub(crate) const DEFAULT_RELAY: &str = "wss://relay.snowbound.paperclover.net"; +/// How long opening a notebook joined for the first time waits for the computer sharing it. +const FIRST_LISTING: std::time::Duration = std::time::Duration::from_secs(15); -/// The room joined and the people met there. +/// Who this computer is to others and where it meets them, as Options last said. +struct Me { + name: String, + picture: bool, + relay: Option, +} + +static ME: Mutex> = Mutex::new(None); + +/// Takes Options' name, picture and relay for every meeting from now on. +pub(crate) fn configure(author: &str, options: &settings::Live) { + *ME.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(Me { + name: author.to_owned(), + picture: options.picture, + relay: options + .relay + .clone() + .filter(|relay| !relay.trim().is_empty()), + }); +} + +/// This computer's greeting: the user's name, and the account's picture where Options lets +/// it go and the name is the account's. +fn hello() -> io::Result { + let me = ME.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + let (name, picture) = me.as_ref().map_or((platform::user_name(), true), |me| { + (me.name.clone(), me.picture) + }); + let picture = (picture && name == platform::user_name()) + .then(|| account_picture().clone()) + .flatten(); + Hello::new(name, picture) +} + +/// The relay peers off this network meet through: `SNOWBOUND_LIVE_RELAY` (`off` for none), +/// else Options', else Snowbound's. +fn relay() -> Option { + match std::env::var("SNOWBOUND_LIVE_RELAY") { + Ok(relay) if relay == "off" => None, + Ok(relay) => Some(relay), + Err(_) => Some( + ME.lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .as_ref() + .and_then(|me| me.relay.clone()) + .unwrap_or_else(|| DEFAULT_RELAY.to_owned()), + ), + } +} + +/// Where peers are looked for on this computer's networks: every network, or with +/// `SNOWBOUND_LIVE=loopback` this computer alone, as two copies side by side in a test do. +fn reach() -> Option { + match std::env::var("SNOWBOUND_LIVE").as_deref() { + Ok("loopback") => Some(Reach::Loopback), + Ok("off") => None, + _ => Some(Reach::Network), + } +} + +/// Where this computer keeps what it shares and what it joined: beside the replicas, which +/// hold the same notebooks' contents. +fn kept(cache: &Path, name: &str) -> PathBuf { + cache.join("live").join(name) +} + +fn read_kept(file: &Path) -> T { + notebook::fs::read(file) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + .unwrap_or_default() +} + +/// Replaces `file` whole, readable by this account alone. +fn keep(file: &Path, value: &impl serde::Serialize) -> io::Result<()> { + let folder = file.parent().unwrap_or(Path::new(".")); + notebook::fs::create_dir_all(folder)?; + let partial = file.with_extension("partial"); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + io::Write::write_all( + &mut options.open(&partial)?, + &serde_json::to_vec_pretty(value)?, + )?; + notebook::fs::rename(partial, file) +} + +/// A notebook another computer shares, as this one joined it. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +struct Share { + share: [u8; 16], + secret: [u8; 16], + notebook: String, + host: String, + /// It has been listed here, so it opens without waiting for the host. + listed: bool, +} + +const JOINED: &str = "joined.json"; +const HOSTING: &str = "hosting.json"; + +/// A notebook another computer shares, open here: the way to it and its names. +pub struct Joined { + pub guest: Arc, + pub name: String, + /// The name of the person sharing it. + pub host: String, + background: OnceLock>, +} + +impl Joined { + /// The notebook shared at `location` and kept in `cache`, or its name and why it can't + /// be read. + pub fn open(location: &str, cache: &Path) -> Result<(Self, Notebook), (String, String)> { + let file = kept(cache, JOINED); + let mut shares: BTreeMap = read_kept(&file); + let Some(share) = shares.get(location).cloned() else { + return Err(( + "Shared notebook".into(), + "This computer no longer has the code for this notebook. Close it, then open it \ + again with a new code." + .into(), + )); + }; + let refused = |error: &dyn std::fmt::Display| (share.notebook.clone(), error.to_string()); + let guest = hello() + .and_then(|me| { + Guest::start( + me, + share.share, + share.secret, + reach(), + relay().as_deref(), + crate::library::notify_background, + ) + }) + .map_err(|error| refused(&error))?; + if !share.listed { + let since = std::time::Instant::now(); + while guest.host().is_none() && since.elapsed() < FIRST_LISTING { + std::thread::sleep(std::time::Duration::from_millis(50)); + } + } + let notebook = Notebook::open_hosted(Arc::clone(&guest), cache).map_err(|_| { + refused(&format!( + "Can’t reach {}’s computer. Make sure Snowbound is open there, then try again.", + share.host + )) + })?; + if !share.listed { + shares.insert( + location.to_owned(), + Share { + listed: true, + ..share.clone() + }, + ); + if let Err(error) = keep(&file, &shares) { + eprintln!("Keeping the shared notebooks: {error}"); + } + } + let joined = Self { + guest, + name: share.notebook, + host: share.host, + background: OnceLock::new(), + }; + Ok((joined, notebook)) + } + + /// Wakes `background` whenever the host comes back. + pub fn follow(&self, background: &Arc) { + let _ = self.background.set(Arc::downgrade(background)); + } +} + +/// Meets whoever shares `code` (and `password`): the share it welcomes this computer to. +pub(crate) fn join( + code: &str, + password: &str, +) -> Result { + let me = hello().map_err(|_| live::share::Refusal::Unreachable)?; + live::share::join(me, code, password, reach(), relay().as_deref()) +} + +/// Keeps `welcome` as the notebook this computer joined: its location. +pub(crate) fn joined(cache: &Path, welcome: live::wire::Welcome) -> io::Result { + let location = live::share::location(&welcome.share); + let file = kept(cache, JOINED); + let mut shares: BTreeMap = read_kept(&file); + shares.insert( + location.clone(), + Share { + share: welcome.share, + secret: welcome.secret, + notebook: welcome.notebook, + host: welcome.host, + listed: false, + }, + ); + keep(&file, &shares)?; + Ok(location) +} + +/// What a thread of its own sends the frame, and where the frame takes it. +pub(crate) type Channel = (mpsc::Sender, mpsc::Receiver); + +/// The presence room joined and the people met there, what this computer shares, and the +/// people's pictures. +#[derive(Default)] pub(crate) struct Peers { - room: Room, - /// None where joining failed, which is tried again only for another room. - live: Option, + /// The shown notebook's presence room: its location and secret, and the meeting, none + /// where joining failed. + room: Option<(String, [u8; 16], Option)>, + /// Each shared notebook's presence secret, by location, read on a thread of its own: + /// `None` while it reads or where it couldn't be read. + secrets: HashMap>, + found: Option)>>, + /// The notebooks this computer shares, by location. + pub(crate) hosts: BTreeMap>, + /// Shares as kept for the next launch. + sharing: Option>, + /// Shares starting on threads of their own, by location. + pub(crate) starting: HashMap>, + started: Option)>>, + /// Whether each joined notebook's host was there last frame. + reached: HashMap, /// Each peer's picture as drawn, cut to a circle, decoded once. pictures: HashMap<[u8; 16], Option>, -} - -/// The relay used when `SNOWBOUND_LIVE_RELAY` names none; `off` keeps peers to the LAN. -const DEFAULT_RELAY: &str = "wss://relay.snowbound.paperclover.net"; - -fn reach() -> Option { - match std::env::var("SNOWBOUND_LIVE").ok()?.as_str() { - "network" => Some(Reach::Network), - "loopback" => Some(Reach::Loopback), - _ => None, - } + pub(crate) share: Option, + pub(crate) join: Option, } impl State { - /// Joins the open notebook's room, leaving any other, and says where this window is. + /// Keeps the presence rooms and shares in step with what is open, and says where this + /// window is: in the shown notebook's room, and in each Live Share's. pub(crate) fn follow_peers(&mut self) { - let (reach, relay) = (reach(), std::env::var("SNOWBOUND_LIVE_RELAY").ok()); - if reach.is_none() && relay.is_none() { - return; - } - let relay = match relay.as_deref() { - Some("off") => None, - Some(_) => relay, - None => Some(DEFAULT_RELAY.to_owned()), + self.follow_shares(); + let shown = self + .session + .as_ref() + .map(|session| Arc::clone(&session.library)); + let here = if self.live_options.presence { + self.presence() + } else { + live::Presence::default() }; - let room = match std::env::var("SNOWBOUND_LIVE_CODE") { - Ok(code) => Some(Room::join(&code, "")), - Err(_) => self - .session - .as_ref() - .and_then(|session| session.library.catalog()?.toc.as_ref()) - .map(|toc| Room::Notebook(toc.file_id)), + let presence = |location: &str| match &shown { + Some(shown) if shown.location == location => here.clone(), + _ => live::Presence::default(), }; - if self.peers.as_ref().map(|peers| &peers.room) != room.as_ref() { - self.peers = room.map(|room| { + for (location, host) in &self.peers.hosts { + host.set_presence(presence(location)); + } + for library in &self.notebooks { + if let Some(joined) = &library.joined { + joined.guest.set_presence(presence(&library.location)); + } + } + let room = shown + .filter(|library| self.live_options.presence && reached_by_others(library)) + .and_then(|library| Some((library.location.clone(), self.secret(&library)?))); + let current = + (self.peers.room.as_ref()).map(|(location, secret, _)| (location.clone(), *secret)); + if current != room { + self.peers.room = room.map(|(location, secret)| { let redraw = self.redraw.clone(); - // The account's picture goes with the account's name, not another chosen in Options. - let picture = (self.author == platform::user_name()) - .then(|| account_picture().clone()) - .flatten(); - let live = Hello::new(self.author.clone(), picture) + let live = hello() .and_then(|me| { - live::Live::start(me, &room, reach, relay.as_deref(), move |_| { - redraw.wake_by_ref() - }) + live::Live::start( + me, + &Room::Notebook(secret), + reach(), + relay().as_deref(), + move |_| redraw.wake_by_ref(), + ) }) .inspect_err(|error| eprintln!("Live presence: {error}")) .ok(); - Peers { - room, - live, - pictures: HashMap::new(), - } + (location, secret, live) }); } - if let Some(live) = self.peers.as_ref().and_then(|peers| peers.live.as_ref()) { - live.set_presence(self.presence()); + if let Some((.., Some(live))) = &self.peers.room { + live.set_presence(here); + } + } + + /// The presence secret of `library`, read on a thread of its own the first time. + fn secret(&mut self, library: &Arc) -> Option<[u8; 16]> { + let (found, arrived) = self.peers.found.get_or_insert_with(mpsc::channel); + for (location, secret) in arrived.try_iter() { + self.peers.secrets.insert(location, secret); + } + if let Some(secret) = self.peers.secrets.get(&library.location) { + return *secret; + } + self.peers.secrets.insert(library.location.clone(), None); + let (found, library, redraw) = (found.clone(), Arc::clone(library), self.redraw.clone()); + crate::spawn(move || { + let secret = match &library.notebook { + Ok(Some(notebook)) => notebook + .presence_room() + .inspect_err(|error| { + eprintln!("{}: no presence room: {error}", library.location) + }) + .ok(), + _ => None, + }; + let _ = found.send((library.location.clone(), secret)); + redraw.wake(); + }); + None + } + + /// Starts the shares kept from before as their notebooks open, stops those whose + /// notebook closed, keeps each share's code for the next launch, and wakes a joined + /// notebook's sync when its host comes back. + fn follow_shares(&mut self) { + let (_, started) = self.peers.started.get_or_insert_with(mpsc::channel); + for (location, host) in started.try_iter().collect::>() { + match host { + Ok(host) => { + self.peers.starting.remove(&location); + self.hosting(location, Arc::new(host)); + } + Err(error) => { + eprintln!("{location}: sharing didn’t start: {error}"); + self.peers.starting.insert(location, Some(error)); + } + } + } + let cache = self.cache.clone(); + let sharing = self + .peers + .sharing + .get_or_insert_with(|| read_kept(&kept(&cache, HOSTING))) + .clone(); + let open: Vec> = self.notebooks.clone(); + for library in &open { + if let Some(kept) = sharing.get(&library.location) + && !self.peers.hosts.contains_key(&library.location) + && !self.peers.starting.contains_key(&library.location) + && matches!(library.notebook, Ok(Some(_))) + { + self.start_sharing(library, kept.clone()); + } + if let Some(joined) = &library.joined { + let reached = joined.guest.host().is_some(); + let before = self.peers.reached.insert(library.location.clone(), reached); + if reached && before == Some(false) { + if let Some(background) = joined.background.get().and_then(Weak::upgrade) { + background.wake(); + } + if let Some(session) = &self.session + && Arc::ptr_eq(&session.library, library) + { + session.section.wake(); + } + } + } + } + let closed: Vec = (self.peers.hosts.keys()) + .filter(|location| !open.iter().any(|library| library.location == **location)) + .cloned() + .collect(); + for location in closed { + self.stop_sharing(&location); + } + let now: BTreeMap = (self.peers.hosts.iter()) + .map(|(location, host)| (location.clone(), host.sharing())) + .collect(); + let before: BTreeMap = (sharing.into_iter()) + .filter(|(location, _)| { + !self.peers.starting.contains_key(location) + && open.iter().any(|library| library.location == *location) + }) + .collect(); + if now != before { + if let Err(error) = keep(&kept(&cache, HOSTING), &now) { + eprintln!("Keeping what this computer shares: {error}"); + } + self.peers.sharing = Some(now); + } + } + + /// Shares `library` as `sharing` says, on a thread of its own. + pub(crate) fn start_sharing(&mut self, library: &Arc, sharing: Sharing) { + self.peers.starting.insert(library.location.clone(), None); + let (started, _) = self.peers.started.get_or_insert_with(mpsc::channel); + let (started, library, redraw) = + (started.clone(), Arc::clone(library), self.redraw.clone()); + crate::spawn(move || { + let host = (|| -> Result> { + let storage = library.reopen()?.into_storage(); + let told = redraw.clone(); + Ok(Host::start( + storage, + hello()?, + sharing, + &library.name, + reach(), + relay().as_deref(), + move || told.wake_by_ref(), + )?) + })(); + let _ = started.send(( + library.location.clone(), + host.map_err(|error| error.to_string()), + )); + redraw.wake(); + }); + } + + /// `host` shares the notebook at `location`: its folder's changes reach its guests. + fn hosting(&mut self, location: String, host: Arc) { + if let Some(background) = self + .library_at(&location) + .and_then(|library| library.background.clone()) + { + let host = Arc::downgrade(&host); + background.on_touched(Some(Box::new(move |paths| { + if let Some(host) = host.upgrade() { + host.touched(paths); + } + }))); + } + self.peers.hosts.insert(location, host); + } + + /// Stops sharing the notebook at `location`: its guests are let go, and its code and + /// secret are never used again. + pub(crate) fn stop_sharing(&mut self, location: &str) { + self.peers.starting.remove(location); + if let Some(background) = self + .library_at(location) + .and_then(|library| library.background.clone()) + { + background.on_touched(None); + } + if let Some(host) = self.peers.hosts.remove(location) { + crate::spawn(move || { + if let Some(host) = Arc::into_inner(host) { + host.stop(); + } + }); } } + fn library_at(&self, location: &str) -> Option<&Arc> { + self.notebooks + .iter() + .find(|library| library.location == location) + } + /// The open section and page, and the caret where it is in stored text. fn presence(&self) -> live::Presence { let Some(session) = &self.session else { @@ -106,12 +509,32 @@ impl State { } } + /// Everyone with the shown notebook open: in its presence room, and in its Live Share, + /// as host or guest. fn connected(&self) -> Vec { - self.peers - .as_ref() - .and_then(|peers| peers.live.as_ref()) - .map(live::Live::peers) - .unwrap_or_default() + let Some(library) = self.session.as_ref().map(|session| &session.library) else { + return Vec::new(); + }; + let mut peers: Vec = Vec::new(); + let rooms = (self.peers.room.iter()) + .filter(|(location, ..)| *location == library.location) + .filter_map(|(.., live)| live.as_ref().map(live::Live::peers)) + .chain( + self.peers + .hosts + .get(&library.location) + .map(|host| host.guests()), + ) + .chain(library.joined.as_ref().map(|joined| joined.guest.peers())); + for peer in rooms.flatten() { + if !peers + .iter() + .any(|known| known.hello.peer == peer.hello.peer) + { + peers.push(peer); + } + } + peers } /// The colours of the peers on each page of the open section. @@ -153,13 +576,10 @@ impl State { ); for peer in peers.iter().rev() { let hello = &peer.hello; - let picture = self.peers.as_mut().and_then(|peers| { - peers - .pictures - .entry(hello.peer) - .or_insert_with(|| hello.picture.as_deref().and_then(circle)) - .clone() - }); + let picture = (self.peers.pictures) + .entry(hello.peer) + .or_insert_with(|| hello.picture.as_deref().and_then(circle)) + .clone(); let avatar = self.ui.open( hello.peer, Spec { @@ -209,9 +629,61 @@ impl State { self.commands.push(Command::OpenPage(page.into())); } } + if self.live_options.presence { + self.seen(); + } self.ui.close(); } + /// The mark beside the avatars that says the others see this window too, which opens + /// Options to turn that off. + fn seen(&mut self) { + let accent = self.ui.theme.accent; + let mark = self.ui.open( + "seen", + Spec { + flags: Flags::CLICKABLE, + size: [px(AVATAR); 2], + pad: [5.0, 5.0], + role: Some(accesskit::Role::Button), + ..Spec::default() + }, + ); + self.ui.open( + "ring", + Spec { + size: [fill(), fill()], + border: Some(accent), + radius: (AVATAR - 10.0) / 2.0, + pad: [3.5, 3.5], + ..Spec::default() + }, + ); + self.ui.leaf( + "dot", + Spec { + size: [fill(), fill()], + fill: Some(accent), + radius: (AVATAR - 17.0) / 2.0, + ..Spec::default() + }, + ); + self.ui.close(); + self.ui.close(); + if let Some(node) = self.ui.access(mark) { + node.set_label("Others see you here"); + } + ui::popup::tooltip( + &mut self.ui, + "Others see you here", + "", + Some("Your name, page and cursor. Turn this off in Options."), + ); + if self.ui.signal(mark).clicked { + self.open_options(); + } + } + /// The page, or the section, `peer` has open. fn place_of(&self, peer: &Peer) -> Option { let session = self.session.as_ref()?; @@ -329,6 +801,16 @@ impl State { } } +/// Whether others may have `library` open too: on a server, in iCloud Drive, or in a folder +/// another computer holds. +fn reached_by_others(library: &Library) -> bool { + library.on_smb() + || library.in_icloud() + || library + .folder() + .is_some_and(|folder| !crate::watch::on_this_computer(folder)) +} + /// The open section's file identity. fn section(session: &Session) -> Option<[u8; 16]> { session diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index c3e4f80c7d4829c4e10c2a597220bee71c69d720..d16d195301afe305848e34344875e0aed6c792d7 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -68,6 +68,8 @@ mod screenshot; mod search; mod server; mod settings; +#[cfg(feature = "live")] +mod share; mod sidebar; #[cfg_attr(target_os = "linux", path = "spell_linux.rs")] #[cfg_attr(target_os = "macos", path = "spell_macos.rs")] @@ -674,6 +676,9 @@ enum Command { /// Asks for a server's address and opens a notebook on it; with a notebook's location, /// signs in again to open it. OpenFromServer(Option), + /// Asks for the code of a notebook another computer shares, and opens it. + #[cfg(feature = "live")] + OpenShared, /// Asks where to keep a new notebook and creates it. NewNotebook, /// Opens the Snowbound Guide from the user's documents, copying it there first. @@ -819,7 +824,9 @@ struct State { /// What has been read in each notebook, on this computer. reads: unread::Reads, #[cfg(feature = "live")] - peers: Option, + peers: live::Peers, + /// Options' Live Share. + live_options: settings::Live, /// Open Notebook from Server while it is open. server: Option, /// Notebooks in iCloud Drive a thread is reading, or following the download of, by @@ -992,6 +999,14 @@ impl State { // Beneath the surface's layer, which is added over it. let backdrop = visible && platform::install_backdrop(&window); commands::Keymap::from_saved(&stored.keys).install(); + #[cfg(feature = "live")] + live::configure( + stored + .user_name + .as_deref() + .unwrap_or(&platform::user_name()), + &stored.live, + ); platform::install_menu(); let access_adapter = AccessAdapter::with_event_loop_proxy(event_loop, &window, proxy.clone()); @@ -1212,7 +1227,7 @@ impl State { asking: Default::default(), reads, #[cfg(feature = "live")] - peers: None, + peers: Default::default(), server: None, icloud_reading: HashSet::new(), folder_rename: None, @@ -1233,6 +1248,7 @@ impl State { locked, password: None, passwords: stored.passwords, + live_options: stored.live, drag: None, templates: templates::View::Strip, media: Default::default(), @@ -1391,6 +1407,8 @@ impl State { self.themes_dialog(); self.print_dialog(); self.server_dialog(); + #[cfg(feature = "live")] + self.live_dialogs(); self.link_dialog()?; self.properties_dialog(); self.symbols_dialog(); @@ -3369,6 +3387,8 @@ impl State { }), ), Command::OpenFromServer(location) => self.open_server(location.as_deref()), + #[cfg(feature = "live")] + Command::OpenShared => self.open_shared(), Command::NewNotebook => self.new_notebook(), Command::OpenGuide => self.open_guide()?, #[cfg(target_os = "linux")] diff --git a/crates/snowbound/src/menubar.rs b/crates/snowbound/src/menubar.rs index 2487c8bb5b79326b0019a7b6105c7dd5a2b5fc05..d44418043cd92602d7109d7e216354132b70b85b 100644 --- a/crates/snowbound/src/menubar.rs +++ b/crates/snowbound/src/menubar.rs @@ -56,6 +56,7 @@ const MENUS: &[Item] = &[ C(Id::NewNotebook), C(Id::OpenNotebook), C(Id::OpenFromServer), + C(Id::OpenShared), C(Id::CloseNotebook), S, C(Id::NewSection), @@ -66,6 +67,7 @@ const MENUS: &[Item] = &[ C(Id::PageVersions), C(Id::CopyPageLink), C(Id::ShowNotebook), + C(Id::LiveShare), S, C(Id::PasswordProtect), C(Id::LockAll), @@ -299,6 +301,11 @@ fn menu( }; for item in items { match item { + // Live Share is still settling; AppKit's menus take no badge before macOS 14. + Item::Command(id @ (Id::LiveShare | Id::OpenShared)) => { + let title = format!("{} (Beta)", commands::command(*id).title); + choose(&Choice::Command(*id), &title, None); + } Item::Command(id) => { choose( &Choice::Command(*id), diff --git a/crates/snowbound/src/menus.rs b/crates/snowbound/src/menus.rs index c316fe3adaf26d20b684929aac62589670ea9ab2..c3fa6857dd8f84299e7a007a648e702ee0b5b794 100644 --- a/crates/snowbound/src/menus.rs +++ b/crates/snowbound/src/menus.rs @@ -122,6 +122,9 @@ pub enum Action { Properties, /// Opens Password Protection on a section. Password, + /// Opens Live Share on a notebook. + #[cfg(feature = "live")] + LiveShare, } /// OneNote 2010's section and notebook colours, COLORREF, with their names in its Section @@ -272,11 +275,16 @@ impl State { // An action, its label, whether it is disabled, and whether a rule starts its group. let item = |action: Action, text, disabled, separated| { let submenu = matches!(action, Action::Move | Action::Copy | Action::Colors); + #[cfg(feature = "live")] + let badge = (action == Action::LiveShare).then_some(crate::share::BETA); + #[cfg(not(feature = "live"))] + let badge = None; let item = Item { text, disabled, separated, submenu, + badge, ..Item::default() }; (action, item) @@ -402,6 +410,13 @@ impl State { false, ), item(Action::CopyLink, "Copy Link to Notebook", false, true), + #[cfg(feature = "live")] + item( + Action::LiveShare, + commands::command(commands::Id::LiveShare).title, + library.catalog().is_none() || library.joined.is_some(), + false, + ), item(Action::NewSection, "New Section", false, true), item(Action::NewGroup, "New Section Group", false, false), item( @@ -724,6 +739,11 @@ impl State { None } (Target::Notebook(library), Action::Close) => Some(Command::CloseNotebook(library)), + #[cfg(feature = "live")] + (Target::Notebook(library), Action::LiveShare) => { + self.open_live_share(library); + None + } (Target::Notebook(library), Action::Rename | Action::Properties) => { self.open_properties(library); None diff --git a/crates/snowbound/src/options.rs b/crates/snowbound/src/options.rs index 6ffa3caac77074aa6f3a6e7fa1d791ae1dcc094c..9b68e084f7ea983dd90fe5f49f3c48d821564f78 100644 --- a/crates/snowbound/src/options.rs +++ b/crates/snowbound/src/options.rs @@ -178,14 +178,37 @@ const SECTIONS: &[Section] = &[ }, Section { name: "Sync & Storage", - groups: &[Group { - heading: "Cache file location", - rows: &[Row { - label: "Path:", - keywords: "folder replica data disk", - control: Control::Field(cache), - }], - }], + groups: &[ + Group { + heading: "Cache file location", + rows: &[Row { + label: "Path:", + keywords: "folder replica data disk", + control: Control::Field(cache), + }], + }, + #[cfg(feature = "live")] + Group { + heading: "Live Share", + rows: &[ + Row { + label: "Show others where I am in shared notebooks", + keywords: "presence privacy cursor caret avatar collaborate people", + control: Control::Check(|options| &mut options.presence), + }, + Row { + label: "Show my account picture with my name", + keywords: "presence privacy photo avatar", + control: Control::Check(|options| &mut options.picture), + }, + Row { + label: "Relay:", + keywords: "live share server internet network presence", + control: Control::Field(relay), + }, + ], + }, + ], }, Section { name: "Updates", @@ -246,6 +269,10 @@ pub struct Options { /// Minutes, as `protection::AFTER` lists them. lock_minutes: u32, lock_on_leave: bool, + presence: bool, + picture: bool, + /// Live Share's relay; empty uses Snowbound's. + relay: String, pub(crate) keyboard: crate::keys::Keyboard, } @@ -342,6 +369,9 @@ impl State { lock_idle: self.passwords.lock_after.is_some(), lock_minutes: self.passwords.lock_after.unwrap_or(10), lock_on_leave: self.passwords.lock_on_leave, + presence: self.live_options.presence, + picture: self.live_options.picture, + relay: self.live_options.relay.clone().unwrap_or_default(), keyboard: crate::keys::Keyboard::new(), }); self.ui.open_popup(id()); @@ -650,6 +680,14 @@ impl State { lock_on_leave: options.lock_on_leave, }; self.updates.set_automatic(options.automatic_updates); + let relay = options.relay.trim(); + self.live_options = crate::settings::Live { + presence: options.presence, + picture: options.picture, + relay: (!relay.is_empty()).then(|| relay.to_owned()), + }; + #[cfg(feature = "live")] + crate::live::configure(&self.author, &self.live_options); self.show_spelling(); self.follow_color_scheme(); self.install_keymap(options.keyboard.keymap); @@ -662,6 +700,30 @@ impl State { } } +#[cfg(feature = "live")] +fn relay(state: &mut State, options: &mut Options) { + let ui = &mut state.ui; + let theme = ui.theme.clone(); + let field = id().child("relay"); + ui::text_field( + ui, + field, + &mut options.relay, + crate::live::DEFAULT_RELAY, + Spec { + size: [fill(), px(row_height(&theme))], + fill: Some(theme.base), + border: Some(theme.chip), + radius: 4.0, + pad: [6.0, 0.0], + ..Spec::default() + }, + ); + if let Some(node) = ui.access(field) { + node.set_label("Live Share relay"); + } +} + fn section_id(section: &Section) -> Id { list().child(section.name) } diff --git a/crates/snowbound/src/settings.rs b/crates/snowbound/src/settings.rs index 4985435b0d4b2347b445a5160aa7c087fe37cb79..de22af5a31355bc0ddbde5b337c4c170644d6390 100644 --- a/crates/snowbound/src/settings.rs +++ b/crates/snowbound/src/settings.rs @@ -51,6 +51,29 @@ pub struct Settings { /// Pages shown lately, latest first, which the palette lists first and a launch returns to. pub recent: Vec, pub passwords: Passwords, + pub live: Live, +} + +/// Options' Live Share. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Live { + /// Others with a shared notebook open see who you are, your page and your cursor. + pub presence: bool, + /// Your account's picture goes with your name. + pub picture: bool, + /// The relay computers on different networks meet through; none uses Snowbound's. + pub relay: Option, +} + +impl Default for Live { + fn default() -> Self { + Self { + presence: true, + picture: true, + relay: None, + } + } } /// Options' Passwords, as OneNote 2010's Advanced page keeps them. @@ -194,6 +217,7 @@ impl crate::State { keys: crate::commands::Keymap::current().saved(), recent: self.trail.recent.clone(), passwords: self.passwords, + live: self.live_options.clone(), }; if let Err(error) = settings.save(path) { eprintln!("Cannot save the settings in {}: {error}", path.display()); @@ -278,6 +302,11 @@ mod tests { lock_after: None, lock_on_leave: true, }, + live: Live { + presence: false, + picture: false, + relay: Some("wss://relay.example.net".into()), + }, }; settings.save(&path).unwrap(); assert_eq!(Settings::load(&path), settings); diff --git a/crates/snowbound/src/share.rs b/crates/snowbound/src/share.rs new file mode 100644 index 0000000000000000000000000000000000000000..1a99f5a8aca811350f6db324e02ac0329d788719 --- /dev/null +++ b/crates/snowbound/src/share.rs @@ -0,0 +1,503 @@ +//! Live Share's dialogs: sharing the notebook shown from this computer, with its code and the +//! people connected, and opening a notebook another computer shares by its code. + +use crate::{Library, State}; +use accesskit::Role; +use notebook::live::{ + Relayed, + share::{self, Refusal, Sharing}, + wire::Welcome, +}; +use std::sync::{Arc, mpsc}; +use ui::{Anchor, Axis, Id, Spec, Ui, children, fill, fit, px}; +use winit::keyboard::NamedKey; + +const WIDTH: f32 = 420.0; +/// What marks Live Share as still settling, wherever it is offered. +pub(crate) const BETA: &str = "BETA"; + +fn share_id() -> Id { + Id::ROOT.child("live-share") +} + +fn join_id() -> Id { + Id::ROOT.child("open-shared") +} + +fn password_field() -> Id { + share_id().child("password") +} + +fn code_field() -> Id { + join_id().child("code") +} + +fn join_password() -> Id { + join_id().child("password") +} + +/// Live Share on one notebook while it is open. +pub(crate) struct ShareDialog { + library: Arc, + protect: bool, + password: String, + copied: bool, +} + +/// Open Shared Notebook while it is open. +pub(crate) struct JoinDialog { + code: String, + password: String, + status: Status, + replies: crate::live::Channel>, +} + +enum Status { + Idle, + Waiting, + Failed(String), +} + +/// What to tell someone whose code didn't open a notebook. +fn refusal(refusal: &Refusal) -> String { + match refusal { + Refusal::Malformed => "Enter the code as it was given, like 412-violet-otter.".into(), + Refusal::Wrong => "That code or password doesn’t open a notebook. Check it with the \ + person sharing." + .into(), + Refusal::NoOne => "No one is sharing with that code. Check the number, and that \ + Snowbound is open on their computer." + .into(), + Refusal::Expired => { + "That code expired after too many wrong tries. Ask for a new one.".into() + } + Refusal::TooMany(wait) => match wait.map(|wait| wait.as_secs().div_ceil(60)) { + Some(1) => "Too many wrong codes from this network. Try again in a minute.".into(), + Some(minutes) => { + format!("Too many wrong codes from this network. Try again in {minutes} minutes.") + } + None => "Too many wrong codes from this network. Try again later.".into(), + }, + Refusal::Busy => "The Live Share relay is busy. Try again in a minute.".into(), + Refusal::Unreachable => { + "Can’t reach the Live Share relay. Check your internet connection.".into() + } + Refusal::TimedOut => "The computer sharing didn’t answer. Try again.".into(), + } +} + +/// A dialog's frame, titled `title` with the BETA badge. +fn frame(ui: &mut Ui, id: Id, title: &str) { + let theme = ui.theme.clone(); + let row = theme.font_size * 2.0; + ui.open_as( + id, + Spec { + axis: Axis::Y, + size: [px(WIDTH), children()], + fill: Some(theme.popup), + border: Some(theme.chip), + shadow: Some(theme.shadow), + radius: 8.0, + pad: [16.0, 12.0], + gap: 6.0, + anchor: Some(Anchor::Dialog), + role: Some(Role::Dialog), + ..Spec::default() + }, + ); + if let Some(node) = ui.access(id) { + node.set_label(title); + } + ui.open( + "title", + Spec { + size: [fill(), px(row)], + gap: 6.0, + ..Spec::default() + }, + ); + ui.leaf( + "text", + Spec { + size: [fit(), px(row)], + text: Some(title), + bold: true, + role: Some(Role::Heading), + ..Spec::default() + }, + ); + ui::badge(ui, "beta", BETA, row); + ui.close(); +} + +/// Text wrapped across the dialog, dim where `dim`. +fn text(ui: &mut Ui, part: &str, text: &str, dim: bool) { + let color = if dim { + ui.theme.text_dim + } else { + ui.theme.text + }; + ui.leaf( + part, + Spec { + size: [fill(), fit()], + text: Some(text), + overflow: ui::Overflow::Wrap, + color: Some(color), + ..Spec::default() + }, + ); +} + +fn status(ui: &mut Ui, message: &str) { + ui.leaf( + "status", + Spec { + size: [fill(), fit()], + text: Some(message), + overflow: ui::Overflow::Wrap, + pad: [0.0, 4.0], + role: Some(Role::Status), + ..Spec::default() + }, + ); +} + +fn field_spec(ui: &Ui) -> Spec<'static> { + let theme = &ui.theme; + Spec { + size: [fill(), px(theme.font_size * 2.0)], + fill: Some(theme.base), + border: Some(theme.accent), + radius: 4.0, + pad: [6.0, 0.0], + ..Spec::default() + } +} + +/// `label` before what `build` makes, in one row. +fn labelled(ui: &mut Ui, label: &str, build: impl FnOnce(&mut Ui)) { + let row = ui.theme.font_size * 2.0; + ui.open( + label, + Spec { + size: [fill(), px(row)], + gap: 8.0, + ..Spec::default() + }, + ); + ui.leaf( + "label", + Spec { + size: [px(80.0), px(row)], + text: Some(label), + ..Spec::default() + }, + ); + build(ui); + ui.close(); +} + +/// Opens a row of buttons, a space taking what they leave. +fn buttons(ui: &mut Ui) { + ui.open( + "buttons", + Spec { + size: [fill(), children()], + pad: [0.0, 8.0], + gap: 8.0, + ..Spec::default() + }, + ); + ui.leaf( + "space", + Spec { + size: [fill(), px(1.0)], + ..Spec::default() + }, + ); +} + +impl State { + /// Opens Live Share on `library`. + pub(crate) fn open_live_share(&mut self, library: Arc) { + self.peers.share = Some(ShareDialog { + library, + protect: false, + password: String::new(), + copied: false, + }); + self.ui.open_popup(share_id()); + } + + /// Opens Open Shared Notebook. + pub(crate) fn open_shared(&mut self) { + self.peers.join = Some(JoinDialog { + code: String::new(), + password: String::new(), + status: Status::Idle, + replies: mpsc::channel(), + }); + self.ui.open_popup(join_id()); + self.ui.focus_all(code_field()); + } + + pub(crate) fn live_dialogs(&mut self) { + self.share_dialog(); + self.join_dialog(); + } + + fn share_dialog(&mut self) { + let Some(mut dialog) = self.peers.share.take() else { + return; + }; + if !self.ui.popup_open(share_id()) { + return; + } + let location = dialog.library.location.clone(); + let host = self.peers.hosts.get(&location).cloned(); + let starting = self.peers.starting.get(&location).cloned(); + let ui = &mut self.ui; + let theme = ui.theme.clone(); + let fields: &[Id] = if host.is_none() && dialog.protect { + &[password_field()] + } else { + &[] + }; + let entered = + ui::popup::navigation(ui, fields, &[NamedKey::Enter]).contains(&NamedKey::Enter); + frame(ui, share_id(), "Live Share"); + text(ui, "notebook", &dialog.library.name, true); + let (mut start, mut stop, mut copy) = (false, false, None); + match (&host, &starting) { + (Some(host), _) => { + let code = host.code(); + ui.open( + "code", + Spec { + size: [fill(), px(theme.font_size * 3.0)], + gap: 8.0, + ..Spec::default() + }, + ); + ui.leaf( + "text", + Spec { + size: [fill(), fill()], + text: Some(code.as_deref().unwrap_or("Getting a code…")), + font_size: Some(if code.is_some() { + theme.font_size * 1.6 + } else { + theme.font_size + }), + bold: code.is_some(), + color: code.is_none().then_some(theme.text_dim), + role: Some(Role::Label), + ..Spec::default() + }, + ); + if let Some(code) = &code { + let label = if dialog.copied { "Copied" } else { "Copy" }; + if ui::button(ui, "copy", label).clicked { + copy = Some(code.clone()); + } + } + ui.close(); + let protected = !host.sharing().password.is_empty(); + text( + ui, + "how", + if protected { + "Others open it with Open Shared Notebook, this code and the password." + } else { + "Others open it with Open Shared Notebook and this code." + }, + true, + ); + match host.relayed() { + Relayed::Unreachable | Relayed::Refused(..) => status( + ui, + "Can’t reach the Live Share relay. Only people on this network can join.", + ), + _ => {} + } + ui.leaf( + "people", + Spec { + size: [fill(), px(theme.font_size * 2.0)], + text: Some("Connected"), + bold: true, + role: Some(Role::Heading), + ..Spec::default() + }, + ); + let guests = host.guests(); + if guests.is_empty() { + text(ui, "nobody", "No one has joined yet.", true); + } + for guest in &guests { + ui.leaf( + ("guest", guest.hello.peer), + Spec { + size: [fill(), px(theme.font_size * 1.6)], + text: Some(&guest.hello.name), + role: Some(Role::ListItem), + ..Spec::default() + }, + ); + } + buttons(ui); + stop = ui::button(ui, "stop", "Stop Sharing").clicked; + } + (None, Some(None)) => { + text(ui, "starting", "Starting…", true); + buttons(ui); + } + (None, failed) => { + text( + ui, + "what", + "People you give the code to can open and edit this notebook while \ + Snowbound is open on this computer.", + false, + ); + if ui::check_box(ui, "protect", "Require a password", dialog.protect).clicked { + dialog.protect = !dialog.protect; + if dialog.protect { + ui.focus_all(password_field()); + } + } + if dialog.protect { + labelled(ui, "Password:", |ui| { + let spec = field_spec(ui); + ui::password_field(ui, password_field(), &mut dialog.password, "", spec); + if let Some(node) = ui.access(password_field()) { + node.set_label("Password"); + } + }); + } + if let Some(Some(error)) = failed { + status(ui, &format!("Couldn’t start sharing: {error}")); + } + buttons(ui); + start = ui::button(ui, "start", "Start Sharing").clicked || entered; + } + } + let done = ui::button(ui, "done", "Done").clicked; + ui.close(); + ui.close(); + if let Some(code) = copy { + dialog.copied = self.clipboard.set_text(code).is_ok(); + } + if stop { + self.stop_sharing(&location); + } + if start && !(dialog.protect && dialog.password.is_empty()) { + let password = if dialog.protect { + dialog.password.clone() + } else { + String::new() + }; + match Sharing::new(&password) { + Ok(sharing) => self.start_sharing(&dialog.library, sharing), + Err(error) => { + self.peers + .starting + .insert(location, Some(error.to_string())); + } + } + } + if done { + self.ui.close_popup(share_id()); + self.ui.set_focus(Some(crate::page())); + return; + } + self.peers.share = Some(dialog); + } + + fn join_dialog(&mut self) { + let Some(mut dialog) = self.peers.join.take() else { + return; + }; + if !self.ui.popup_open(join_id()) { + return; + } + let mut welcomed = None; + for reply in dialog.replies.1.try_iter() { + match reply { + Ok(welcome) => welcomed = Some(welcome), + Err(refused) => dialog.status = Status::Failed(refusal(&refused)), + } + } + if let Some(welcome) = welcomed { + match crate::live::joined(&self.cache, welcome) { + Ok(location) => { + self.ui.close_popup(join_id()); + self.open_notebook(location, None); + return; + } + Err(error) => { + dialog.status = Status::Failed(format!("Couldn’t keep the code: {error}")); + } + } + } + let ui = &mut self.ui; + let entered = + ui::popup::navigation(ui, &[code_field(), join_password()], &[NamedKey::Enter]) + .contains(&NamedKey::Enter); + frame(ui, join_id(), "Open Shared Notebook"); + text(ui, "what", "Enter the code from the person sharing.", false); + labelled(ui, "Code:", |ui| { + let spec = field_spec(ui); + ui::text_field(ui, code_field(), &mut dialog.code, "412-violet-otter", spec); + if let Some(node) = ui.access(code_field()) { + node.set_label("Code"); + } + }); + labelled(ui, "Password:", |ui| { + let spec = field_spec(ui); + ui::password_field( + ui, + join_password(), + &mut dialog.password, + "If they set one", + spec, + ); + if let Some(node) = ui.access(join_password()) { + node.set_label("Password"); + } + }); + match &dialog.status { + Status::Idle => {} + Status::Waiting => status(ui, "Connecting…"), + Status::Failed(message) => status(ui, message), + } + buttons(ui); + let cancel = ui::button(ui, "cancel", "Cancel").clicked; + let open = ui::button(ui, "open", "Open").clicked || entered; + ui.close(); + ui.close(); + if cancel { + self.ui.close_popup(join_id()); + self.ui.set_focus(Some(crate::page())); + return; + } + if open && !matches!(dialog.status, Status::Waiting) { + match share::code(&dialog.code) { + None => dialog.status = Status::Failed(refusal(&Refusal::Malformed)), + Some(code) => { + dialog.status = Status::Waiting; + let (replies, redraw) = (dialog.replies.0.clone(), self.redraw.clone()); + let password = dialog.password.clone(); + crate::spawn(move || { + let reply = crate::live::join(&code, &password); + let _ = replies.send(reply); + redraw.wake(); + }); + } + } + } + self.peers.join = Some(dialog); + } +} diff --git a/crates/snowbound/src/sidebar.rs b/crates/snowbound/src/sidebar.rs index 8468784e67f1e36f26cf919e51165b3272c1d218..53d25b69159c81672f93e2eea935bd39acaf0ada 100644 --- a/crates/snowbound/src/sidebar.rs +++ b/crates/snowbound/src/sidebar.rs @@ -1258,6 +1258,13 @@ impl crate::State { "Open Notebook from Server…", crate::Command::OpenFromServer(None), ), + #[cfg(feature = "live")] + ( + "shared", + Leading::Icon(art::LINK), + "Open Shared Notebook…", + crate::Command::OpenShared, + ), ] .into_iter() .chain(crate::guide::OFFERED.then_some(( diff --git a/crates/snowbound/src/sync.rs b/crates/snowbound/src/sync.rs index 8aa3994d90eb23bd9baf0c687f21a89a072f7ec7..1afc76c366dc9064054792228e8bc3fac3c5a6a6 100644 --- a/crates/snowbound/src/sync.rs +++ b/crates/snowbound/src/sync.rs @@ -265,6 +265,8 @@ struct Facts<'a> { changes_listed: bool, /// Whether the open section's file can be shown in the file manager. local: bool, + /// The computer sharing this notebook by Live Share stopped sharing it. + stopped: bool, } /// What the reader picked in the popup. @@ -457,6 +459,9 @@ fn build(ui: &mut Ui, facts: &Facts) -> Picked { let conflict; // A problem's advice stays while working offline, so turning it on moves nothing. let advice = match state { + SyncState::NotConnected if facts.stopped => Some(format!( + "The person sharing this notebook stopped sharing it. Your changes stay on {THIS}." + )), SyncState::NotConnected => Some(match sync.queued { 0 => format!("Can’t reach {host}. Sync continues when it’s back."), _ => format!("Can’t reach {host}. {waiting} will sync when it’s back."), @@ -1044,6 +1049,13 @@ impl State { color: library.color(), location: &library.location, place: library.place(), + #[cfg(feature = "live")] + stopped: library + .joined + .as_ref() + .is_some_and(|joined| joined.guest.stopped()), + #[cfg(not(feature = "live"))] + stopped: false, notice: library.notice.as_deref(), sections: sections(&library, session), conflicts: session @@ -1160,6 +1172,7 @@ mod tests { update: &IDLE, changes_listed: false, local: true, + stopped: false, } } diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index cc5ac585f3fc7c7613f8c06bad9a3c1681075c9e..c5a839fdd2c2ebd69dbe390de7eb47422704c93a 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -15,8 +15,8 @@ mod widgets; pub use list::{List, Row, Rows, list}; pub use theme::{Menu, PopupMotion, Section, Shades, Shadow, Theme}; pub use widgets::{ - PaintedScroller, Scroller, ScrollerPart, Scrollers, button, check_box, edit_key, - edit_modifiers, password_field, scrollbar, text_field, + PaintedScroller, Scroller, ScrollerPart, Scrollers, badge, badge_width, button, check_box, + edit_key, edit_modifiers, password_field, scrollbar, text_field, }; use draw::{ diff --git a/crates/ui/src/popup.rs b/crates/ui/src/popup.rs index 39e4ff04d147b524b9a859e3095fea82fecabffe..046474847c736b6e6115a8c5ac0a95e74ddb4ccb 100644 --- a/crates/ui/src/popup.rs +++ b/crates/ui/src/popup.rs @@ -77,6 +77,8 @@ pub struct Item<'a> { pub heading: bool, /// Repeats an item listed further on, so shows only while unfiltered. pub repeated: bool, + /// A tag after the text, as `badge` draws one. + pub badge: Option<&'a str>, } impl Item<'_> { @@ -107,20 +109,31 @@ pub fn menu( return None; } let style = ui.theme.menu(); + let badges: Vec = (items.iter()) + .map(|item| { + item.badge + .map_or(0.0, |badge| crate::badge_width(ui, badge) + ICON_GAP) + }) + .collect(); let mut measure = |text| ui.texts.label(text, style.font_size, ui.frame).size[0]; - let [text, shortcut] = items.iter().fold([0.0_f32; 2], |[text, shortcut], item| { - [ - text.max( - measure(item.text) - + if item.highlight.is_some() { - 2.0 * SAMPLE_PAD - } else { - 0.0 - }, - ), - shortcut.max(measure(item.trailing())), - ] - }); + let [text, shortcut] = + items + .iter() + .zip(&badges) + .fold([0.0_f32; 2], |[text, shortcut], (item, badge)| { + [ + text.max( + measure(item.text) + + badge + + if item.highlight.is_some() { + 2.0 * SAMPLE_PAD + } else { + 0.0 + }, + ), + shortcut.max(measure(item.trailing())), + ] + }); let width = text + if shortcut > 0.0 { 3.0 * ICON_GAP + shortcut @@ -575,6 +588,9 @@ fn menu_row(ui: &mut Ui, style: &Menu, matches: &Matches, owner: Role, row: Row) _ => Role::MenuItem, }); node.set_label(item.text); + if let Some(badge) = item.badge { + node.set_description(badge); + } if let Some(checked) = item.checked { node.set_toggled(checked.into()); } @@ -686,6 +702,25 @@ fn menu_row(ui: &mut Ui, style: &Menu, matches: &Matches, owner: Role, row: Row) }, ); } + None if item.badge.is_some() => { + ui.leaf( + "text", + Spec { + size: [fit(), fill()], + ..text + }, + ); + if let Some(badge) = item.badge { + crate::badge(ui, "badge", badge, style.row); + } + ui.leaf( + "rest", + Spec { + size: [fill(), fill()], + ..Spec::default() + }, + ); + } None => { ui.leaf("text", text); } diff --git a/crates/ui/src/widgets.rs b/crates/ui/src/widgets.rs index 912c46e201adc48c50125b56edabbb8236814ae1..75c8a56b8fff87e5cb0f1950fcdc5890b39b0f13 100644 --- a/crates/ui/src/widgets.rs +++ b/crates/ui/src/widgets.rs @@ -32,6 +32,42 @@ pub fn button(ui: &mut Ui, part: impl Hash, text: &str) -> Signal { ui.leaf(part, spec) } +/// A short tag in the accent's colour, centred in a box `height` tall, as BETA marks a +/// feature still settling beside its name. +pub fn badge(ui: &mut Ui, part: impl Hash, text: &str, height: f32) { + let theme = &ui.theme; + let [red, green, blue, _] = theme.accent; + let tall = (theme.font_size * 1.15).min(height).round(); + let spec = Spec { + size: [fit(), px(tall)], + text: Some(text), + font_size: Some((theme.font_size * 0.7).round()), + bold: true, + color: Some(theme.accent), + fill: Some([red, green, blue, 0.16]), + radius: 3.0, + pad: [4.0, 0.0], + center: true, + ..Spec::default() + }; + ui.open( + part, + Spec { + size: [crate::children(), px(height)], + pad: [0.0, ((height - tall) / 2.0).max(0.0)], + ..Spec::default() + }, + ); + ui.leaf("text", spec); + ui.close(); +} + +/// The width `badge` takes for `text`. +pub fn badge_width(ui: &mut Ui, text: &str) -> f32 { + let size = (ui.theme.font_size * 0.7).round(); + ui.texts.label(text, size, ui.frame).size[0] + 8.0 +} + /// A check box before `label`, `checked` or not; a click on either reports for the caller /// to toggle. pub fn check_box(ui: &mut Ui, part: impl Hash, label: &str, checked: bool) -> Signal { diff --git a/tools/canvas/build_macos.py b/tools/canvas/build_macos.py index 8918d7fb53a5e2745853900c54cee34b3080ec50..40ca523d5ce721c505d22d578485bc00f282a2cc 100644 --- a/tools/canvas/build_macos.py +++ b/tools/canvas/build_macos.py @@ -146,7 +146,9 @@ if build: 'NSPrincipalClass': 'NSApplication', 'CFBundleIconFile': 'Snowbound', 'CFBundleIconName': 'Snowbound-Tahoe', - 'NSLocalNetworkUsageDescription': 'Snowbound connects to file servers on your network to open and sync shared notebooks.', + 'NSLocalNetworkUsageDescription': 'Snowbound connects to file servers and other Snowbounds on your network to open and sync shared notebooks.', + # Live presence and Live Share find other Snowbounds by Bonjour. + 'NSBonjourServices': ['_snowbound._tcp'], 'NSMicrophoneUsageDescription':'Snowbound records audio into your notes when you choose Record Audio or Record Video.', 'NSCameraUsageDescription': 'Snowbound records video into your notes when you choose Record Video.', 'NSUbiquitousContainers': {CONTAINER: {