From 3d84683db457cfb9c19b314a937cbce6b1b8091d Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 2 Oct 2026 18:24:39 -0700 Subject: [PATCH] feat: Live Share in the app (beta), and live presence on by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:// and opened from its last listing while the host is away; a share and the notebooks joined come back after a relaunch. Wrong codes, codes no one shares, expired codes, too many tries and an unreachable relay each say so. Presence is on for notebooks others reach too (on a server, in iCloud Drive, in another computer's folder, by Live Share), in the shown notebook's room only, and the mark beside the avatars says others see you. Options ▸ Sync & Storage ▸ Live Share turns it off, leaves the account picture out, or names another relay. Menu items take a badge (ui::badge); macOS's bundle lists _snowbound._tcp for Bonjour. Assisted-by: claude-opus-5.5 --- arc/sync.md | 24 +- crates/snowbound/src/commands.rs | 29 ++ crates/snowbound/src/library.rs | 82 +++- crates/snowbound/src/live.rs | 620 +++++++++++++++++++++++++++---- crates/snowbound/src/main.rs | 24 +- crates/snowbound/src/menubar.rs | 7 + crates/snowbound/src/menus.rs | 20 + crates/snowbound/src/options.rs | 78 +++- crates/snowbound/src/settings.rs | 29 ++ crates/snowbound/src/share.rs | 503 +++++++++++++++++++++++++ crates/snowbound/src/sidebar.rs | 7 + crates/snowbound/src/sync.rs | 13 + crates/ui/src/lib.rs | 4 +- crates/ui/src/popup.rs | 61 ++- crates/ui/src/widgets.rs | 36 ++ tools/canvas/build_macos.py | 4 +- 16 files changed, 1442 insertions(+), 99 deletions(-) create mode 100644 crates/snowbound/src/share.rs 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: { -- 2.54.0