authorclover caruso <git@paperclover.net> 2026-10-11 21:36:47-07:00
committerclover caruso <git@paperclover.net> 2026-10-11 21:37:35-07:00
log8e74dcce15aac05c790b8a2ad57cfb37d0f2f18c
tree15f721408108ce02c571a346b1e72cbc6adf4660
parent0b3748b14dba39c904ac5359c0560831ef382139
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

feat(mobile): expose native Live Share guest sessions

Reuse hosted notebook discovery and original replicas for cached browsing, queued edits, search, and explicit sync. Apply the initial offline policy before watching cached queues. Assisted-by: gpt-6.1-sol

5 files changed, 861 insertions(+), 11 deletions(-)

Cargo.lock+1
......@@ -2068,6 +2068,7 @@ dependencies = [
20682068 "onestore",
20692069 "parley",
20702070 "pollster",
2071 "relay",
20712072 "resvg",
20722073 "serde",
20732074 "serde_json",
crates/mobile/Cargo.toml+2-1
......@@ -12,7 +12,7 @@ crate-type = ["staticlib"]
1212crash-report = { path = "../crash-report" }
1313canvas = { path = "../canvas", features = ["interaction", "pdf"] }
1414draw = { path = "../draw" }
15notebook = { path = "../notebook", features = ["smb"] }
15notebook = { path = "../notebook", features = ["smb", "live"] }
1616onestore = { path = "../onestore" }
1717wgpu.workspace = true
1818parley.workspace = true
......@@ -28,4 +28,5 @@ objc2 = "0.5.2"
2828objc2-foundation = { version = "0.2.2", default-features = false, features = ["std", "NSArray", "NSEnumerator", "NSRange", "NSString", "NSThread"] }
2929
3030[dev-dependencies]
31relay = { path = "../relay" }
3132tempfile = "3"
crates/mobile/include/snowbound.h+9
......@@ -30,6 +30,15 @@ bool sb_tag_icon(uint16_t shape, bool checked, uint32_t pixels, uint8_t *rgba);
3030const Library *sb_library_open(const char *path, const char *cache, bool local, char **error);
3131const Library *sb_library_server(const char *address, const char *share, const char *user, const char *password,
3232 const char *domain, const char *root, const char *cache, char **error);
33// Callback/token live until the blocking join returns; false cancels, approving reports host approval.
34typedef bool (*sb_join_wait)(uintptr_t token, bool approving);
35// Private credential/refusal JSON; persist credential before hosted open, then free with sb_string_free.
36char *sb_live_join(const char *author, const char *device, const char *code, const char *password,
37 const char *relay, bool local_network, sb_join_wait waiting, uintptr_t token);
38// Null relay disables relay, false local_network disables LAN; offline guards cached edits before first watch.
39const Library *sb_library_hosted(const char *credential, const char *author, const char *device,
40 const char *relay, bool local_network, bool offline, const char *cache, char **error);
41char *sb_library_live_status(const Library *library);
3342bool sb_notebook_create(const char *path, const char *cache, const char *author, const char *date, const char *time,
3443 char **error);
3544bool sb_notebook_moved(const char *cache, const char *from, const char *to, char **error);
crates/mobile/src/library.rs+266-10
......@@ -7,6 +7,7 @@ use canvas::search::{Entry, Index, Query, Tagged};
77use notebook::{
88 Remote, Replica,
99 discover::{Folder, Reason, SectionState},
10 live::{self, Hello, Reach, share::Guest},
1011 session::{self, Background, Event, Known, Notebook, SyncStatus},
1112 smb::{Client, Credentials},
1213};
......@@ -221,6 +222,17 @@ enum Place {
221222 client: Mutex<Option<Arc<Client>>>,
222223 root: String,
223224 },
225 Hosted(Arc<Guest>),
226}
227
228#[derive(serde::Serialize, serde::Deserialize)]
229#[serde(deny_unknown_fields)]
230struct Joined {
231 share: [u8; 16],
232 secret: [u8; 16],
233 protocol: u16,
234 notebook: String,
235 host: String,
224236}
225237
226238/// A share notebook's sections as last listed, so they open from their replicas while the
......@@ -437,6 +449,59 @@ impl Library {
437449 Ok(library)
438450 }
439451
452 fn hosted(
453 joined: Joined,
454 author: String,
455 device: Option<String>,
456 relay: Option<&str>,
457 local_network: bool,
458 offline: bool,
459 cache: &Path,
460 ) -> Result<Self> {
461 if joined.protocol != live::wire::VERSION {
462 return Err(io::Error::from(io::ErrorKind::Unsupported).into());
463 }
464 let mut hello = Hello::new(author, None)?;
465 hello.device = device;
466 let guest = Guest::start(
467 hello,
468 joined.share,
469 joined.secret,
470 local_network.then_some(Reach::Network),
471 relay,
472 sync_woken,
473 )?;
474 let mut notebook = match Notebook::open_hosted(Arc::clone(&guest), cache) {
475 Ok(notebook) => notebook,
476 Err(error) if guest.host().is_none() => {
477 let deadline = Instant::now() + TIMEOUT;
478 while guest.host().is_none() && guest.ended().is_none() && Instant::now() < deadline
479 {
480 std::thread::sleep(Duration::from_millis(50));
481 }
482 if guest.host().is_none() {
483 return Err(error.into());
484 }
485 Notebook::open_hosted(Arc::clone(&guest), cache)?
486 }
487 Err(error) => return Err(error.into()),
488 };
489 let background = Background::hosted(Arc::clone(&guest), sync_woken)?;
490 background.set_offline(offline);
491 background.watch(notebook.replicas());
492 Ok(Self {
493 notebook: Mutex::new(Some(notebook)),
494 place: Place::Hosted(guest),
495 cache: cache.to_owned(),
496 index: Mutex::default(),
497 background: Some(background),
498 open: Mutex::default(),
499 offline: AtomicBool::new(offline),
500 keys: Default::default(),
501 pause: Duration::ZERO,
502 })
503 }
504
440505 /// Has the background sync a share notebook's sections, `files` as `Listed` keeps them.
441506 fn watch(&self, files: &BTreeMap<String, String>) {
442507 let (Some(background), Some(replicas)) = (&self.background, self.share_replicas()) else {
......@@ -574,8 +639,12 @@ impl Library {
574639 files: BTreeMap::new(),
575640 };
576641 tabs(notebook.catalog(), &self.unlocked(), &mut listed.tabs);
577 files(notebook.catalog(), &mut listed.files);
578 if let (Place::Folder(_), Some(background)) = (&self.place, &self.background) {
642 if matches!(self.place, Place::Share { .. }) {
643 files(notebook.catalog(), &mut listed.files);
644 }
645 if let (Place::Folder(_) | Place::Hosted(_), Some(background)) =
646 (&self.place, &self.background)
647 {
579648 background.watch(notebook.replicas());
580649 }
581650 Ok(listed)
......@@ -690,6 +759,24 @@ impl Library {
690759 notify,
691760 )?)
692761 }
762 Place::Hosted(guest) => {
763 let notebook = self.notebook();
764 let notebook = notebook
765 .as_ref()
766 .ok_or_else(|| io::Error::from(io::ErrorKind::NotConnected))?;
767 let cache = notebook.replica_path(path)?;
768 std::fs::create_dir_all(cache.parent().unwrap_or(&self.cache))?;
769 let key = self.unlocked().get(path).cloned();
770 let replica =
771 Replica::open_or_create(&cache, key.as_ref(), || notebook.read_section(path))?;
772 Ok(session::Section::resume_hosted(
773 path.to_owned(),
774 replica,
775 Arc::clone(guest),
776 offline,
777 notify,
778 )?)
779 }
693780 }
694781 }
695782}
......@@ -1136,14 +1223,7 @@ impl Library {
11361223 for path in &tabs {
11371224 match open.iter().find(|(open, _)| open == path) {
11381225 Some((_, shared)) => {
1139 let section = &shared.section;
1140 let mut entries = Vec::new();
1141 for (space, ..) in section.pages()? {
1142 let modified = index.get(path, space).map_or(0, |entry| entry.modified);
1143 entries.push(Entry::new(path, space, &section.page(space)?, modified));
1144 }
1145 index.retain(|entry| entry.section != *path);
1146 entries.into_iter().for_each(|entry| index.set(entry));
1226 index_replica(index, path, shared.section.replica())?;
11471227 checked.remove(path);
11481228 }
11491229 None => {
......@@ -1446,6 +1526,20 @@ impl Library {
14461526 }
14471527 Place::Folder(root) => local(root.join(path))?,
14481528 Place::File(file) => local(file.clone())?,
1529 Place::Hosted(_) => {
1530 let cache = self
1531 .notebook()
1532 .as_ref()
1533 .ok_or_else(|| io::Error::from(io::ErrorKind::NotConnected))?
1534 .replica_path(path)?;
1535 let key = self.unlocked().get(path).cloned();
1536 let replica = Replica::open_or_create(cache, key.as_ref(), || {
1537 Err(io::Error::from(io::ErrorKind::NotFound).into())
1538 })?;
1539 index_replica(index, path, &replica)?;
1540 checked.remove(path);
1541 return Ok(());
1542 }
14491543 };
14501544 if checked.get(path).is_none_or(|known| known.stamp != stamp) {
14511545 let pages = session::stored_pages(&read()?)?;
......@@ -1468,6 +1562,17 @@ impl Library {
14681562 }
14691563}
14701564
1565fn index_replica(index: &mut Index, path: &str, replica: &Replica) -> Result<()> {
1566 let mut entries = Vec::new();
1567 for (space, ..) in replica.pages()? {
1568 let modified = index.get(path, space).map_or(0, |entry| entry.modified);
1569 entries.push(Entry::new(path, space, &replica.page(space)?, modified));
1570 }
1571 index.retain(|entry| entry.section != path);
1572 entries.into_iter().for_each(|entry| index.set(entry));
1573 Ok(())
1574}
1575
14711576type Reader = Box<dyn Fn() -> Result<Vec<u8>>>;
14721577
14731578/// A local file's change key, its length and modification time, with its reader.
......@@ -1523,6 +1628,157 @@ pub(crate) fn optional(text: *const c_char) -> Option<String> {
15231628 (!text.is_null()).then(|| string(text))
15241629}
15251630
1631/// Runs on the joining thread, with a token the host keeps alive until `sb_live_join`
1632/// returns; false cancels the join, and `approving` reports pending host approval.
1633pub type JoinWait = extern "C" fn(token: usize, approving: bool) -> bool;
1634
1635/// Joins through explicitly supplied relay/LAN routes, blocking off the UI thread. Returns
1636/// private JSON containing `credential`, or a machine-readable `refusal`; free it with
1637/// `sb_string_free`. The caller must durably keep the credential before opening its hosted library.
1638/// Cancellation cannot undo an admission the host has already granted.
1639///
1640/// # Safety
1641/// `author`, `code` and `password` are NUL-terminated UTF-8; `device` and `relay` may be null.
1642/// `waiting` and its token remain valid until this call returns; callbacks never overlap.
1643#[unsafe(no_mangle)]
1644pub unsafe extern "C" fn sb_live_join(
1645 author: *const c_char,
1646 device: *const c_char,
1647 code: *const c_char,
1648 password: *const c_char,
1649 relay: *const c_char,
1650 local_network: bool,
1651 waiting: JoinWait,
1652 token: usize,
1653) -> *mut c_char {
1654 use live::share::Refusal;
1655 let password = zeroize::Zeroizing::new(string(password));
1656 let relay = optional(relay);
1657 let joined = Hello::new(string(author), None)
1658 .map_err(|_| Refusal::Unreachable(live::Trouble::Other))
1659 .and_then(|mut hello| {
1660 hello.device = optional(device);
1661 live::share::join_while(
1662 hello,
1663 &string(code),
1664 &password,
1665 local_network.then_some(Reach::Network),
1666 relay.as_deref(),
1667 |approving| waiting(token, approving),
1668 )
1669 });
1670 let value = match joined {
1671 Ok(welcome) => serde_json::json!({"credential": Joined {
1672 share: welcome.share,
1673 secret: welcome.secret,
1674 protocol: live::wire::VERSION,
1675 notebook: welcome.notebook,
1676 host: welcome.host,
1677 }}),
1678 Err(refusal) => {
1679 let reason = match refusal {
1680 Refusal::Malformed => "malformed",
1681 Refusal::Version { theirs_newer } => {
1682 return json(Ok(
1683 serde_json::json!({"refusal": "version", "theirs_newer": theirs_newer}),
1684 ));
1685 }
1686 Refusal::Wrong => "wrong",
1687 Refusal::NoOne => "no_one",
1688 Refusal::Expired => "expired",
1689 Refusal::TooMany(wait) => {
1690 return json(Ok(
1691 serde_json::json!({"refusal": "too_many", "retry_seconds": wait.map(|wait| wait.as_secs_f64())}),
1692 ));
1693 }
1694 Refusal::Busy => "busy",
1695 Refusal::Unreachable(trouble) => {
1696 let (trouble, status) = match trouble {
1697 live::Trouble::Dns => ("dns", None),
1698 live::Trouble::Unreachable => ("unreachable", None),
1699 live::Trouble::TimedOut => ("timed_out", None),
1700 live::Trouble::ProxyUnreachable => ("proxy_unreachable", None),
1701 live::Trouble::ProxyAuthentication => ("proxy_authentication", None),
1702 live::Trouble::ProxyRefused(status) => ("proxy_refused", Some(status)),
1703 live::Trouble::Certificate => ("certificate", None),
1704 live::Trouble::Blocked => ("blocked", None),
1705 live::Trouble::Other => ("other", None),
1706 };
1707 return json(Ok(
1708 serde_json::json!({"refusal": "unreachable", "trouble": trouble, "status": status}),
1709 ));
1710 }
1711 Refusal::TimedOut => "timed_out",
1712 Refusal::Declined => "declined",
1713 Refusal::Cancelled => "cancelled",
1714 Refusal::NotAdmitted => "not_admitted",
1715 };
1716 serde_json::json!({"refusal": reason})
1717 }
1718 };
1719 json(Ok(value))
1720}
1721
1722/// Reopens an already persisted join against its original replicas. A cached notebook
1723/// opens while the host is absent; the first opening waits at most ten seconds to meet it.
1724/// `offline` applies before cached edits are watched.
1725/// Neither this function nor `sb_live_join` persists credentials.
1726///
1727/// # Safety
1728/// `credential`, `author` and `cache` are NUL-terminated UTF-8; `device` and `relay` may be
1729/// null. `credential` is the private object returned by `sb_live_join`, without its wrapper.
1730/// `error` is null or a place for a string; credential parse errors never echo its contents.
1731#[unsafe(no_mangle)]
1732pub unsafe extern "C" fn sb_library_hosted(
1733 credential: *const c_char,
1734 author: *const c_char,
1735 device: *const c_char,
1736 relay: *const c_char,
1737 local_network: bool,
1738 offline: bool,
1739 cache: *const c_char,
1740 error: *mut *mut c_char,
1741) -> *const Library {
1742 let credential = zeroize::Zeroizing::new(string(credential));
1743 let relay = optional(relay);
1744 shared(
1745 serde_json::from_str(&credential)
1746 .map_err(|_| io::Error::from(io::ErrorKind::InvalidData).into())
1747 .and_then(|joined| {
1748 Library::hosted(
1749 joined,
1750 string(author),
1751 optional(device),
1752 relay.as_deref(),
1753 local_network,
1754 offline,
1755 Path::new(&string(cache)),
1756 )
1757 }),
1758 error,
1759 )
1760}
1761
1762/// Derived Live Share status (`connected`, `not_connected`, `stopped`, or `removed`) and
1763/// the connected host's name, never its credential; null for other notebook sources.
1764/// On return to connected, the host can call `sb_library_sync_now` while not Work Offline.
1765#[unsafe(no_mangle)]
1766pub extern "C" fn sb_library_live_status(library: &Library) -> *mut c_char {
1767 let Place::Hosted(guest) = &library.place else {
1768 return std::ptr::null_mut();
1769 };
1770 let host = guest.host();
1771 let state = match (host.is_some(), guest.ended()) {
1772 (true, _) => "connected",
1773 (_, Some(live::share::Ended::Stopped)) => "stopped",
1774 (_, Some(live::share::Ended::Removed)) => "removed",
1775 _ => "not_connected",
1776 };
1777 json(Ok(
1778 serde_json::json!({"state": state, "host": host.map(|host| host.name.clone())}),
1779 ))
1780}
1781
15261782/// The notebook folder or lone section file at `path`, its section replicas kept in the
15271783/// directory `cache`; null with `error` set if it cannot be read. A folder `local` to this
15281784/// device has the host report its changes to `sb_library_touched`; any other is checked
crates/mobile/src/tests.rs+583
......@@ -1738,3 +1738,586 @@ fn detached_editor_keeps_selection_and_undo_when_reattachment_fails() {
17381738 assert!(sb_view_undo(&mut view, true));
17391739 assert_eq!(view.canvas.active().shown_text(), text);
17401740}
1741
1742struct LiveRelay(std::process::Child);
1743
1744impl LiveRelay {
1745 fn start(test: &str, root: &Path) -> (Self, String) {
1746 let receipt = root.join("relay-address");
1747 let child = std::process::Command::new(std::env::current_exe().unwrap())
1748 .args(["--exact", test, "--nocapture"])
1749 .env("SNOWBOUND_MOBILE_TEST_RELAY", &receipt)
1750 .stdout(std::process::Stdio::null())
1751 .spawn()
1752 .unwrap();
1753 let mut relay = Self(child);
1754 assert!(eventually(|| {
1755 assert!(relay.0.try_wait().unwrap().is_none());
1756 receipt.exists()
1757 }));
1758 (relay, std::fs::read_to_string(receipt).unwrap())
1759 }
1760}
1761
1762impl Drop for LiveRelay {
1763 fn drop(&mut self) {
1764 let _ = self.0.kill();
1765 let _ = self.0.wait();
1766 }
1767}
1768
1769fn live_relay_child() {
1770 if let Some(receipt) = std::env::var_os("SNOWBOUND_MOBILE_TEST_RELAY") {
1771 std::thread::spawn(|| {
1772 std::thread::sleep(Duration::from_secs(180));
1773 std::process::exit(124);
1774 });
1775 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1776 let receipt = PathBuf::from(receipt);
1777 let partial = receipt.with_extension("partial");
1778 std::fs::write(&partial, format!("ws://{}", listener.local_addr().unwrap())).unwrap();
1779 std::fs::rename(partial, receipt).unwrap();
1780 relay::server::serve(listener, Default::default()).unwrap();
1781 }
1782}
1783
1784fn live_host(
1785 root: &Path,
1786 cache: &Path,
1787 sharing: notebook::live::share::Sharing,
1788 relay: &str,
1789) -> notebook::live::share::Host {
1790 let kept = cache.join("sharing.json");
1791 let notebook = notebook::session::Notebook::open(root, cache).unwrap();
1792 notebook::live::share::Host::start(
1793 notebook.into_storage(),
1794 notebook::live::Hello::new("Host".into(), None).unwrap(),
1795 sharing,
1796 "Mobile fixture",
1797 None,
1798 Some(relay),
1799 || {},
1800 move |sharing| keep_live_fixture(&kept, sharing),
1801 )
1802 .unwrap()
1803}
1804
1805fn keep_live_fixture(path: &Path, value: &impl serde::Serialize) -> std::io::Result<()> {
1806 use std::io::Write;
1807 let mut options = std::fs::OpenOptions::new();
1808 options.write(true).create(true).truncate(true);
1809 #[cfg(unix)]
1810 std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
1811 let partial = path.with_extension("partial");
1812 let mut output = options.open(&partial)?;
1813 output.write_all(&serde_json::to_vec(value)?)?;
1814 output.sync_all()?;
1815 std::fs::rename(partial, path)?;
1816 #[cfg(unix)]
1817 std::fs::File::open(path.parent().unwrap())?.sync_all()?;
1818 Ok(())
1819}
1820
1821struct JoinControl {
1822 deadline: Instant,
1823 cancelled: AtomicBool,
1824 approving: AtomicBool,
1825}
1826
1827impl JoinControl {
1828 fn new() -> Self {
1829 Self {
1830 deadline: Instant::now() + Duration::from_secs(30),
1831 cancelled: AtomicBool::new(false),
1832 approving: AtomicBool::new(false),
1833 }
1834 }
1835}
1836
1837extern "C" fn live_join_wait(token: usize, approving: bool) -> bool {
1838 // SAFETY: join_live keeps its borrowed JoinControl alive until sb_live_join returns.
1839 let control = unsafe { &*(token as *const JoinControl) };
1840 control.approving.store(approving, Ordering::Release);
1841 !control.cancelled.load(Ordering::Acquire) && Instant::now() < control.deadline
1842}
1843
1844fn take_json(raw: *mut c_char) -> serde_json::Value {
1845 assert!(!raw.is_null());
1846 let text = unsafe { CString::from_raw(raw) };
1847 serde_json::from_slice(text.as_bytes()).unwrap()
1848}
1849
1850fn join_live(code: &str, password: &str, relay: &str, control: &JoinControl) -> serde_json::Value {
1851 let code = CString::new(code).unwrap();
1852 let password = CString::new(password).unwrap();
1853 let relay = CString::new(relay).unwrap();
1854 take_json(unsafe {
1855 library::sb_live_join(
1856 c"Mobile guest".as_ptr(),
1857 c"Owned test device".as_ptr(),
1858 code.as_ptr(),
1859 password.as_ptr(),
1860 relay.as_ptr(),
1861 false,
1862 live_join_wait,
1863 control as *const JoinControl as usize,
1864 )
1865 })
1866}
1867
1868fn hosted_c(
1869 credential: &serde_json::Value,
1870 relay: &str,
1871 offline: bool,
1872 cache: &Path,
1873) -> Arc<Library> {
1874 let credential = CString::new(serde_json::to_vec(credential).unwrap()).unwrap();
1875 let relay = CString::new(relay).unwrap();
1876 let cache = CString::new(cache.to_str().unwrap()).unwrap();
1877 let mut error = std::ptr::null_mut();
1878 let raw = unsafe {
1879 library::sb_library_hosted(
1880 credential.as_ptr(),
1881 c"Mobile guest".as_ptr(),
1882 c"Owned test device".as_ptr(),
1883 relay.as_ptr(),
1884 false,
1885 offline,
1886 cache.as_ptr(),
1887 &mut error,
1888 )
1889 };
1890 if !error.is_null() {
1891 let error = unsafe { CString::from_raw(error) };
1892 panic!("hosted open: {}", error.to_string_lossy());
1893 }
1894 assert!(!raw.is_null());
1895 // SAFETY: sb_library_hosted hands the caller one owned Arc reference.
1896 unsafe { Arc::from_raw(raw) }
1897}
1898
1899#[test]
1900fn live_join_cancels_approval_and_returns_private_per_device_credentials() {
1901 live_relay_child();
1902 let directory = tempfile::tempdir().unwrap();
1903 let (_relay, url) = LiveRelay::start(
1904 "tests::live_join_cancels_approval_and_returns_private_per_device_credentials",
1905 directory.path(),
1906 );
1907 let root = directory.path().join("notebook");
1908 let cache = directory.path().join("host-cache");
1909 let creation = onestore::PageCreation::new(None, Some("Mobile fixture"), "Fixture").unwrap();
1910 drop(notebook::session::Notebook::create(&root, &cache, 0, &creation).unwrap());
1911 let mut sharing = notebook::live::share::Sharing::new("join password").unwrap();
1912 sharing.approve = true;
1913 let host = live_host(&root, &cache, sharing, &url);
1914 assert!(eventually(|| host.code().is_some()));
1915 let code = host.code().unwrap();
1916 assert_eq!(
1917 join_live("malformed", "", &url, &JoinControl::new())["refusal"],
1918 "malformed"
1919 );
1920 assert_eq!(
1921 join_live(&code, "wrong password", &url, &JoinControl::new())["refusal"],
1922 "wrong"
1923 );
1924 assert!(host.devices().is_empty());
1925 for decision in ["cancel", "decline", "allow"] {
1926 let control = JoinControl::new();
1927 let reply = std::thread::scope(|scope| {
1928 let joined = scope.spawn(|| join_live(&code, "join password", &url, &control));
1929 assert!(eventually(
1930 || control.approving.load(Ordering::Acquire) && host.requests().len() == 1
1931 ));
1932 match decision {
1933 "cancel" => control.cancelled.store(true, Ordering::Release),
1934 "decline" => host.decline(&host.requests()[0].peer),
1935 "allow" => host.allow(&host.requests()[0].peer).unwrap(),
1936 _ => unreachable!(),
1937 }
1938 joined.join().unwrap()
1939 });
1940 match decision {
1941 "allow" => {
1942 let credential = &reply["credential"];
1943 assert!(credential.is_object());
1944 let persisted: notebook::live::share::Sharing =
1945 serde_json::from_slice(&std::fs::read(cache.join("sharing.json")).unwrap())
1946 .unwrap();
1947 assert!(credential["secret"] == serde_json::json!(persisted.members[0].secret));
1948 assert!(credential["secret"] != serde_json::json!(persisted.secret));
1949 assert!(credential.get("room").is_none());
1950 assert!(credential.get("code").is_none());
1951 assert!(credential.get("password").is_none());
1952 assert_eq!(credential["protocol"], notebook::live::wire::VERSION);
1953 let kept = directory.path().join("joined.json");
1954 keep_live_fixture(&kept, credential).unwrap();
1955 let credential: serde_json::Value =
1956 serde_json::from_slice(&std::fs::read(kept).unwrap()).unwrap();
1957 let library = hosted_c(
1958 &credential,
1959 &url,
1960 false,
1961 &directory.path().join("guest-cache"),
1962 );
1963 assert!(eventually(|| take_json(library::sb_library_live_status(
1964 &library
1965 ))["state"]
1966 == "connected"));
1967 assert_eq!(library.tabs().unwrap().len(), 1);
1968 }
1969 _ => {
1970 assert_eq!(
1971 reply["refusal"],
1972 if decision == "cancel" {
1973 "cancelled"
1974 } else {
1975 "declined"
1976 }
1977 );
1978 assert!(reply.get("credential").is_none());
1979 assert!(host.devices().is_empty());
1980 assert!(eventually(|| host.requests().is_empty()));
1981 }
1982 }
1983 }
1984}
1985
1986#[test]
1987fn live_guests_reopen_original_offline_edits_search_and_resume_closed_sections() {
1988 live_relay_child();
1989 let directory = tempfile::tempdir().unwrap();
1990 let (_relay, url) = LiveRelay::start(
1991 "tests::live_guests_reopen_original_offline_edits_search_and_resume_closed_sections",
1992 directory.path(),
1993 );
1994 let root = directory.path().join("notebook");
1995 std::fs::create_dir_all(&root).unwrap();
1996 for (file, text) in [
1997 ("Read.one", "Original mobile text"),
1998 ("Other.one", "Closed section text"),
1999 ] {
2000 std::fs::write(
2001 root.join(file),
2002 onestore::create_section(file, text, "Fixture").unwrap(),
2003 )
2004 .unwrap();
2005 }
2006 let sealed = onestore::create_section("Sealed.one", "Protected fixture", "Fixture").unwrap();
2007 std::fs::write(
2008 root.join("Sealed.one"),
2009 onestore::protected::rekey(
2010 &sealed,
2011 None,
2012 Some(&onestore::protected::Key::new("section password").unwrap()),
2013 )
2014 .unwrap(),
2015 )
2016 .unwrap();
2017 let sealed = std::fs::read(root.join("Sealed.one")).unwrap();
2018 let host_cache = directory.path().join("host-cache");
2019 let host = live_host(
2020 &root,
2021 &host_cache,
2022 notebook::live::share::Sharing::new("").unwrap(),
2023 &url,
2024 );
2025 assert!(eventually(|| host.code().is_some()));
2026 let joined = join_live(&host.code().unwrap(), "", &url, &JoinControl::new());
2027 let kept = directory.path().join("joined.json");
2028 assert!(joined["credential"].is_object());
2029 keep_live_fixture(&kept, &joined["credential"]).unwrap();
2030 let credential: serde_json::Value =
2031 serde_json::from_slice(&std::fs::read(&kept).unwrap()).unwrap();
2032 let credential = &credential;
2033 let cache = directory.path().join("guest-cache");
2034 let library = hosted_c(credential, &url, false, &cache);
2035 let tabs = library.tabs().unwrap();
2036 assert!(
2037 tabs.iter()
2038 .any(|tab| tab.path == "Sealed.one" && tab.locked && !tab.readable)
2039 );
2040 let share: [u8; 16] = serde_json::from_value(credential["share"].clone()).unwrap();
2041 let copies = notebook::location::folder(&cache, &notebook::live::share::location(&share));
2042 let replicas = || {
2043 let mut paths: Vec<_> = std::fs::read_dir(&copies)
2044 .unwrap()
2045 .map(|entry| entry.unwrap().path())
2046 .filter(|path| {
2047 path.extension()
2048 .is_some_and(|extension| extension == "sqlite")
2049 })
2050 .collect();
2051 paths.sort();
2052 paths
2053 };
2054 assert!(eventually(|| copies.exists() && replicas().len() == 2));
2055 let original_replicas = replicas();
2056 assert!(eventually(|| library
2057 .sync_status()
2058 .iter()
2059 .filter(|sync| sync.state == SyncState::UpToDate as u8)
2060 .count()
2061 == 2));
2062 let sharing = host.sharing();
2063 drop(host);
2064 assert!(eventually(|| take_json(library::sb_library_live_status(
2065 &library
2066 ))["state"]
2067 != "connected"));
2068 sb_library_set_offline(&library, true);
2069 let section = open(&library, "Read.one");
2070 let space = section.shared.section.pages().unwrap()[0].0;
2071 let page = section.shared.section.page(space).unwrap();
2072 let text = page
2073 .objects
2074 .iter()
2075 .find_map(|object| match object {
2076 onestore::page::PageObject::Outline(outline) => {
2077 outline.paragraphs.iter().find_map(|paragraph| {
2078 paragraph
2079 .text()
2080 .filter(|text| text.text.text().contains("Original mobile text"))
2081 .map(|text| text.id)
2082 })
2083 }
2084 _ => None,
2085 })
2086 .unwrap();
2087 section
2088 .shared
2089 .apply(Edit {
2090 at: library::filetime(),
2091 ops: vec![Op::Page {
2092 space,
2093 op: PageOp::Text {
2094 text,
2095 range: 0..0,
2096 with: "OFFLINE_GUEST_TOKEN ".into(),
2097 },
2098 }],
2099 })
2100 .unwrap();
2101 section.shared.section.written().unwrap();
2102 let pending = section.shared.section.pending().unwrap();
2103 assert_eq!(pending.len(), 1);
2104 let identity = section.shared.section.replica().identity().unwrap();
2105 let shared = Arc::clone(&section.shared);
2106 drop((section, library));
2107 Arc::into_inner(shared).unwrap().section.close().unwrap();
2108
2109 let reopened: serde_json::Value =
2110 serde_json::from_slice(&std::fs::read(&kept).unwrap()).unwrap();
2111 let library = hosted_c(&reopened, &url, true, &cache);
2112 assert!(
2113 library
2114 .tabs()
2115 .unwrap()
2116 .iter()
2117 .any(|tab| tab.path == "Read.one" && tab.readable)
2118 );
2119 assert_eq!(replicas(), original_replicas);
2120 let section = open(&library, "Read.one");
2121 assert_eq!(
2122 section.shared.section.replica().identity().unwrap(),
2123 identity
2124 );
2125 assert_eq!(section.shared.section.pending().unwrap(), pending);
2126 assert!(
2127 canvas::search::page_text(&section.shared.section.page(space).unwrap())
2128 .contains("OFFLINE_GUEST_TOKEN")
2129 );
2130 let shared = Arc::clone(&section.shared);
2131 drop(section);
2132 Arc::into_inner(shared).unwrap().section.close().unwrap();
2133 assert_eq!(
2134 library::search(&[&library], None, "OFFLINE_GUEST_TOKEN")
2135 .unwrap()
2136 .len(),
2137 1
2138 );
2139 assert_eq!(std::fs::read(root.join("Sealed.one")).unwrap(), sealed);
2140 assert!(!stored_text(&root.join("Read.one"), space).contains("OFFLINE_GUEST_TOKEN"));
2141 let host = live_host(&root, &host_cache, sharing, &url);
2142 assert!(eventually(|| take_json(library::sb_library_live_status(
2143 &library
2144 ))["state"]
2145 == "connected"));
2146 let original = std::fs::read(root.join("Read.one")).unwrap();
2147 drop(library);
2148 let library = hosted_c(&reopened, &url, true, &cache);
2149 assert!(eventually(|| take_json(library::sb_library_live_status(
2150 &library
2151 ))["state"]
2152 == "connected"));
2153 assert_eq!(replicas(), original_replicas);
2154 for _ in 0..10 {
2155 let retained: Vec<_> = original_replicas
2156 .iter()
2157 .flat_map(|path| notebook::Replica::open(path).unwrap().pending().unwrap())
2158 .collect();
2159 assert_eq!(retained, pending);
2160 assert_eq!(std::fs::read(root.join("Read.one")).unwrap(), original);
2161 std::thread::sleep(Duration::from_millis(20));
2162 }
2163 // Closed-section search cannot override Work Offline while the host is reachable.
2164 for _ in 0..32 {
2165 assert_eq!(
2166 library::search(&[&library], None, "OFFLINE_GUEST_TOKEN")
2167 .unwrap()
2168 .len(),
2169 1
2170 );
2171 assert_eq!(std::fs::read(root.join("Read.one")).unwrap(), original);
2172 }
2173 let retained: Vec<_> = original_replicas
2174 .iter()
2175 .flat_map(|path| notebook::Replica::open(path).unwrap().pending().unwrap())
2176 .collect();
2177 assert_eq!(retained, pending);
2178 let section = open(&library, "Read.one");
2179 assert_eq!(
2180 section.shared.section.replica().identity().unwrap(),
2181 identity
2182 );
2183 for _ in 0..10 {
2184 assert_eq!(section.shared.section.pending().unwrap(), pending);
2185 assert_eq!(std::fs::read(root.join("Read.one")).unwrap(), original);
2186 std::thread::sleep(Duration::from_millis(20));
2187 }
2188 assert!(library.sync_status().iter().any(|sync| sync.queued == 1));
2189 sb_library_sync_now(&library);
2190 assert!(eventually(|| library
2191 .sync_status()
2192 .iter()
2193 .all(|sync| sync.queued == 0)));
2194 assert_eq!(
2195 stored_text(&root.join("Read.one"), space)
2196 .matches("OFFLINE_GUEST_TOKEN")
2197 .count(),
2198 1
2199 );
2200 assert_eq!(std::fs::read(root.join("Sealed.one")).unwrap(), sealed);
2201 assert!(section.shared.section.pending().unwrap().is_empty());
2202 let shared = Arc::clone(&section.shared);
2203 drop(section);
2204 Arc::into_inner(shared).unwrap().section.close().unwrap();
2205 sb_library_set_offline(&library, false);
2206 let other_join = join_live(&host.code().unwrap(), "", &url, &JoinControl::new());
2207 let other_kept = directory.path().join("other-joined.json");
2208 keep_live_fixture(&other_kept, &other_join["credential"]).unwrap();
2209 let other_credential: serde_json::Value =
2210 serde_json::from_slice(&std::fs::read(other_kept).unwrap()).unwrap();
2211 let other = hosted_c(
2212 &other_credential,
2213 &url,
2214 false,
2215 &directory.path().join("other-guest-cache"),
2216 );
2217 let secret: [u8; 16] = serde_json::from_value(credential["secret"].clone()).unwrap();
2218 host.remove(&secret).unwrap();
2219 assert!(eventually(|| take_json(library::sb_library_live_status(
2220 &library
2221 ))["state"]
2222 == "removed"));
2223 let unaffected = open(&other, "Read.one");
2224 unaffected
2225 .shared
2226 .apply(Edit {
2227 at: library::filetime(),
2228 ops: vec![Op::Page {
2229 space,
2230 op: PageOp::Text {
2231 text,
2232 range: 0..0,
2233 with: "OTHER_GUEST_TOKEN ".into(),
2234 },
2235 }],
2236 })
2237 .unwrap();
2238 assert!(unaffected.flush(Duration::from_secs(20)));
2239 assert_eq!(
2240 stored_text(&root.join("Read.one"), space)
2241 .matches("OTHER_GUEST_TOKEN")
2242 .count(),
2243 1
2244 );
2245 sb_library_set_offline(&library, true);
2246 let section = open(&library, "Read.one");
2247 section
2248 .shared
2249 .apply(Edit {
2250 at: library::filetime(),
2251 ops: vec![Op::Page {
2252 space,
2253 op: PageOp::Text {
2254 text,
2255 range: 0..0,
2256 with: "REMOVED_GUEST_TOKEN ".into(),
2257 },
2258 }],
2259 })
2260 .unwrap();
2261 section.shared.section.written().unwrap();
2262 let pending = section.shared.section.pending().unwrap();
2263 sb_library_sync_now(&library);
2264 assert!(eventually(|| library.sync_status().iter().any(|sync| sync
2265 .path
2266 == "Read.one"
2267 && sync.state == SyncState::NotConnected as u8
2268 && sync.queued == 1)));
2269 assert_eq!(section.shared.section.pending().unwrap(), pending);
2270 let shared = Arc::clone(&section.shared);
2271 drop((section, library));
2272 Arc::into_inner(shared).unwrap().section.close().unwrap();
2273 let reopened: serde_json::Value =
2274 serde_json::from_slice(&std::fs::read(&kept).unwrap()).unwrap();
2275 let library = hosted_c(&reopened, &url, true, &cache);
2276 let section = open(&library, "Read.one");
2277 assert_eq!(section.shared.section.pending().unwrap(), pending);
2278 assert!(
2279 canvas::search::page_text(&section.shared.section.page(space).unwrap())
2280 .contains("REMOVED_GUEST_TOKEN")
2281 );
2282 assert!(!stored_text(&root.join("Read.one"), space).contains("REMOVED_GUEST_TOKEN"));
2283 assert_eq!(std::fs::read(root.join("Sealed.one")).unwrap(), sealed);
2284}
2285
2286#[test]
2287fn hosted_credentials_reject_unknown_versions_without_echoing_secret_input() {
2288 let directory = tempfile::tempdir().unwrap();
2289 let cache = directory.path().join("must-not-be-created");
2290 let cache = CString::new(cache.to_str().unwrap()).unwrap();
2291 let wrong_version = serde_json::json!({
2292 "share": ([1u8; 16]), "secret": ([2u8; 16]),
2293 "protocol": notebook::live::wire::VERSION + 1,
2294 "notebook": "Fixture", "host": "Host",
2295 })
2296 .to_string();
2297 for input in ["{SECRET_MUST_NOT_BE_ECHOED", wrong_version.as_str()] {
2298 let input = CString::new(input).unwrap();
2299 let mut error = std::ptr::null_mut();
2300 let opened = unsafe {
2301 library::sb_library_hosted(
2302 input.as_ptr(),
2303 c"Guest".as_ptr(),
2304 std::ptr::null(),
2305 std::ptr::null(),
2306 false,
2307 true,
2308 cache.as_ptr(),
2309 &mut error,
2310 )
2311 };
2312 assert!(opened.is_null());
2313 assert!(!error.is_null());
2314 let error = unsafe { CString::from_raw(error) };
2315 assert!(
2316 !error
2317 .to_bytes()
2318 .windows(b"SECRET_MUST_NOT_BE_ECHOED".len())
2319 .any(|bytes| bytes == b"SECRET_MUST_NOT_BE_ECHOED")
2320 );
2321 }
2322 assert!(!Path::new(cache.to_str().unwrap()).exists());
2323}