From 201763c9e037596207365ab10fd3ed5a6b9a6ff4 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Thu, 15 Oct 2026 10:48:28 -0700 Subject: [PATCH] feat(mobile): open provider notebooks through the C surface The phone apps can now open a notebook folder or a lone section that another app keeps, through a document provider the host registers as a table of functions. Sections open from their offline copies and keep their edits while the provider cannot be reached, and a notebook whose access was revoked refuses to open without losing the edits waiting on this device. On Android, finishing the page no longer deletes a notebook's offline copies: it publishes what it can, the open section's edits included, and keeps them, so a notebook reopened while offline shows what it held. Closing a notebook still deletes them, though Android has no way to close one yet. Refs #24 Assisted-by: claude-opus-5.5 --- apps/android/jni/snowbound.c | 10 +- crates/mobile/include/snowbound.h | 25 +++ crates/mobile/src/lib.rs | 1 + crates/mobile/src/library.rs | 174 ++++++++++++++--- crates/mobile/src/provider.rs | 194 +++++++++++++++++++ crates/mobile/src/tests.rs | 298 ++++++++++++++++++++++++++++++ 6 files changed, 670 insertions(+), 32 deletions(-) create mode 100644 crates/mobile/src/provider.rs diff --git a/apps/android/jni/snowbound.c b/apps/android/jni/snowbound.c index db412449d54eab81ea6b9c9f6a904de4c7d67643..e4c343b88a4b5c53e12e67ee5a0562ef9901bc55 100644 --- a/apps/android/jni/snowbound.c +++ b/apps/android/jni/snowbound.c @@ -354,13 +354,15 @@ JNIEXPORT void JNICALL JNI(freeView)(JNIEnv *env, jclass cls, jlong view) { sb_view_free((View *)(intptr_t)view); } -// Frees the section, then publishes the notebook's edits and lets go of it: false, the edits -// kept to publish when it next opens, when they don't publish within `seconds`. +// Publishes the notebook's edits, the open section's included, then frees the section and lets go +// of the notebook, keeping its offline copies: false, the edits kept to publish when it next +// opens, when they don't publish within `seconds`. Finishing the activity is not Close Notebook +// (sb_library_close). JNIEXPORT jboolean JNICALL JNI(close)(JNIEnv *env, jclass cls, jlong library, jlong section, jdouble seconds) { (void)env, (void)cls; - sb_section_free((Section *)(intptr_t)section); char *error = NULL; - bool closed = sb_library_close((const Library *)(intptr_t)library, seconds, &error); + bool closed = sb_library_flush((const Library *)(intptr_t)library, seconds, &error); + sb_section_free((Section *)(intptr_t)section); if (error) { fprintf(stderr, "snowbound: %s\n", error); sb_string_free(error); diff --git a/crates/mobile/include/snowbound.h b/crates/mobile/include/snowbound.h index 54c37c870335cd38871a5b1038dc0750e81f0624..123aed94ec3579195971a8a0f72916323cc4f0d3 100644 --- a/crates/mobile/include/snowbound.h +++ b/crates/mobile/include/snowbound.h @@ -27,7 +27,30 @@ char *sb_tags(void); char *sb_pens(uint32_t section); bool sb_tag_icon(uint16_t shape, bool checked, uint32_t pixels, uint8_t *rgba); +// A document provider, as Android's ContentResolver reaches picked documents by URI. Each call +// returns 0, or 1 while the provider can't be reached, 2 once the grant is revoked, 3 for a +// document gone, anything else for another failure; size and modified (ms since 1970) are -1 +// where unknown. Every field is required. Calls come concurrently from the caller's thread, the +// background sync thread and each section's worker, and the host attaches those threads to its +// runtime (the JVM) itself; callbacks run only within the call that takes them. Ids and names +// passed either way are non-null. +typedef void (*sb_document_found)(void *context, const char *id, const char *name, bool directory, int64_t size, + int64_t modified, bool writable); +typedef struct sb_provider { + int32_t (*document)(const char *id, sb_document_found found, void *context); + int32_t (*children)(const char *id, sb_document_found found, void *context); + // mode is "r" or "rw"; fd is a duplicate Snowbound owns and closes, handle the descriptor + // the host keeps open: detachFd would end the channel checkError reads. A nonzero status + // sets neither; a negative fd with status 0 has its handle closed. + int32_t (*open)(const char *id, const char *mode, int32_t *fd, uint64_t *handle); + // Once fd is closed: the provider's error (checkError), then the host's close. + int32_t (*close)(uint64_t handle); + int32_t (*rename)(const char *id, const char *name, void (*renamed)(void *context, const char *id), void *context); +} sb_provider; +void sb_set_provider(const sb_provider *provider); + const Library *sb_library_open(const char *path, const char *cache, bool local, char **error); +const Library *sb_library_provider(const char *root, bool folder, bool local, const char *cache, char **error); const Library *sb_library_server(const char *address, const char *share, const char *user, const char *password, const char *domain, const char *root, const char *cache, char **error); // Callback/token live until the blocking join returns; false cancels, approving reports host approval. @@ -57,6 +80,8 @@ char *sb_library_sync_status(const Library *library); void sb_library_set_offline(const Library *library, bool offline); void sb_library_touched(const Library *library, const char *path); void sb_library_sync_now(const Library *library); +// Publishes, keeping offline copies; sb_library_close is Close Notebook and deletes them. +bool sb_library_flush(const Library *library, double seconds, char **error); bool sb_library_close(const Library *library, double seconds, char **error); void sb_library_free(const Library *library); diff --git a/crates/mobile/src/lib.rs b/crates/mobile/src/lib.rs index 5e1044a8d7fc9f85d554e101c07869558265a851..2e4a0859f92268f5ad2961767f0041a2c78f1728 100644 --- a/crates/mobile/src/lib.rs +++ b/crates/mobile/src/lib.rs @@ -9,6 +9,7 @@ mod crash; mod library; +mod provider; mod recording; #[cfg(target_os = "ios")] mod spell; diff --git a/crates/mobile/src/library.rs b/crates/mobile/src/library.rs index f316334c0269445e0bd7ab4ec965df3630052bc0..9e4f19042262c61b0bc85861d0bccb0aa206553d 100644 --- a/crates/mobile/src/library.rs +++ b/crates/mobile/src/library.rs @@ -8,6 +8,7 @@ use notebook::{ Remote, Replica, discover::{Folder, Reason, SectionState}, live::{self, Hello, Reach, share::Guest}, + provider::Documents, session::{self, Background, Event, Known, Notebook, SyncStatus}, smb::{Client, Credentials}, }; @@ -223,6 +224,11 @@ enum Place { root: String, }, Hosted(Arc), + /// A notebook folder a document provider keeps. + Provider(Arc), + /// A section a document provider keeps, opened on its own, with its name when it opened: + /// none while the provider can't be reached, the host keeping the name it was picked by. + Document(Arc, Option), } #[derive(serde::Serialize, serde::Deserialize)] @@ -502,6 +508,43 @@ impl Library { }) } + /// The notebook folder, or with `folder` false the lone section, whose document `root` the + /// registered provider keeps (`sb_set_provider`). A provider `local` to this device + /// publishes each edit at once; any other waits for a pause in typing. While the provider + /// can't be reached, the folder opens as last listed and sections from their offline copies. + fn provider(root: &str, folder: bool, local: bool, cache: &Path) -> Result { + let host = crate::provider::Host::default(); + let name = (!folder) + .then(|| { + Some( + notebook::provider::Provider::document(&host, root) + .ok()? + .name, + ) + }) + .flatten(); + let documents = Arc::new(Documents::new(host, root)); + let (notebook, place, background) = if folder { + let mut notebook = Notebook::open_provider(Arc::clone(&documents), cache)?; + let background = Background::provider(Arc::clone(&documents), sync_woken)?; + background.watch(notebook.replicas()); + (Some(notebook), Place::Provider(documents), Some(background)) + } else { + (None, Place::Document(documents, name), None) + }; + Ok(Self { + notebook: Mutex::new(notebook), + place, + cache: cache.to_owned(), + index: Mutex::default(), + background, + open: Mutex::default(), + offline: AtomicBool::new(false), + keys: Default::default(), + pause: if local { Duration::ZERO } else { PAUSE }, + }) + } + /// Has the background sync a share notebook's sections, `files` as `Listed` keeps them. fn watch(&self, files: &BTreeMap) { let (Some(background), Some(replicas)) = (&self.background, self.share_replicas()) else { @@ -619,11 +662,17 @@ impl Library { /// The notebook's sections, read again. pub(crate) fn tabs(&self) -> Result> { - if let Place::File(file) = &self.place { - let path = file.to_string_lossy().into_owned(); + let lone = match &self.place { + Place::File(file) => Some((stem(&file.to_string_lossy()), file.to_string_lossy())), + Place::Document(_, name) => { + Some((stem(name.as_deref().unwrap_or_default()), "".into())) + } + _ => None, + }; + if let Some((name, path)) = lone { return Ok(vec![Tab { - name: stem(&path), - path, + name, + path: path.into_owned(), group: String::new(), color: srgb(SECTION_COLOR), readable: true, @@ -642,7 +691,7 @@ impl Library { if matches!(self.place, Place::Share { .. }) { files(notebook.catalog(), &mut listed.files); } - if let (Place::Folder(_) | Place::Hosted(_), Some(background)) = + if let (Place::Folder(_) | Place::Hosted(_) | Place::Provider(_), Some(background)) = (&self.place, &self.background) { background.watch(notebook.replicas()); @@ -698,7 +747,7 @@ impl Library { fn section_once( &self, path: &str, - notify: impl Fn() + Send + 'static, + notify: impl Fn() + Send + Sync + 'static, ) -> Result { let offline = self.offline.load(Ordering::Relaxed); match &self.place { @@ -759,26 +808,43 @@ impl Library { notify, )?) } - Place::Hosted(guest) => { - let notebook = self.notebook(); - let notebook = notebook - .as_ref() - .ok_or_else(|| io::Error::from(io::ErrorKind::NotConnected))?; - let cache = notebook.replica_path(path)?; - std::fs::create_dir_all(cache.parent().unwrap_or(&self.cache))?; - let key = self.unlocked().get(path).cloned(); - let replica = - Replica::open_or_create(&cache, key.as_ref(), || notebook.read_section(path))?; - Ok(session::Section::resume_hosted( - path.to_owned(), - replica, - Arc::clone(guest), - offline, - notify, - )?) - } + Place::Hosted(guest) => Ok(session::Section::resume_hosted( + path.to_owned(), + self.served_replica(path)?, + Arc::clone(guest), + offline, + notify, + )?), + Place::Provider(documents) => Ok(session::Section::resume_provider( + path.to_owned(), + self.served_replica(path)?, + Arc::clone(documents), + offline, + notify, + )?), + Place::Document(documents, _) => Ok(session::Section::open_provider( + Arc::clone(documents), + &self.cache, + offline, + notify, + )?), } } + + /// The replica of the section at catalog `path` of a notebook another program serves, + /// made from the section where there is none. + fn served_replica(&self, path: &str) -> Result { + let notebook = self.notebook(); + let notebook = notebook + .as_ref() + .ok_or_else(|| io::Error::from(io::ErrorKind::NotConnected))?; + let cache = notebook.replica_path(path)?; + std::fs::create_dir_all(cache.parent().unwrap_or(&self.cache))?; + let key = self.unlocked().get(path).cloned(); + Ok(Replica::open_or_create(&cache, key.as_ref(), || { + notebook.read_section(path) + })?) + } } /// What saving has come to, as the page shows it. @@ -1039,7 +1105,7 @@ impl Section { let listed = section.pages()?; let mut ops = vec![Op::Section(SectionOp::Delete(vec![space]))]; if listed.iter().any(|(page, ..)| *page == space) { - if !matches!(self.library.place, Place::File(_)) { + if !matches!(self.library.place, Place::File(_) | Place::Document(..)) { let page = section.page(space)?; let author = self.shared.author(); self.library.with_notebook(true, |notebook| { @@ -1280,6 +1346,16 @@ impl Library { /// notebook as OneNote closes one: nothing syncs from then on, and each replica holding /// nothing unpublished is deleted once no session holds it. An error leaves it syncing. pub(crate) fn close(&self, limit: Duration) -> Result<()> { + self.flush(limit)?; + if let Some(background) = &self.background { + background.discard(); + } + Ok(()) + } + + /// Publishes every edit the notebook's replicas hold within `limit`, keeping them and the + /// offline copies. + pub(crate) fn flush(&self, limit: Duration) -> Result<()> { const WAITING: &str = "Some changes haven’t been saved to the notebook yet."; let deadline = Instant::now() + limit; let open = self.open_sections(); @@ -1338,7 +1414,6 @@ impl Library { } std::thread::sleep(Duration::from_millis(50)); } - background.discard(); Ok(()) } @@ -1542,7 +1617,9 @@ impl Library { } Place::Folder(root) => local(root.join(path))?, Place::File(file) => local(file.clone())?, - Place::Hosted(_) => { + // Indexed from its session while it is open. + Place::Document(..) => return Ok(()), + Place::Hosted(_) | Place::Provider(_) => { let cache = self .notebook() .as_ref() @@ -2127,8 +2204,49 @@ pub unsafe extern "C" fn sb_library_close( .is_ok() } +/// Publishes every edit the notebook holds within `seconds`, keeping its offline copies, as an +/// app does that stops while the notebook stays open: false, with `error` set, while an edit +/// is still unpublished. Close Notebook is `sb_library_close`. +/// /// # Safety -/// `library` came from `sb_library_open` or `sb_library_server` and is not used again. +/// `error` is null or a place for a string. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_library_flush( + library: &Library, + seconds: f64, + error: *mut *mut c_char, +) -> bool { + library + .flush(Duration::from_secs_f64(seconds)) + .map_err(|cause| failed(cause, error)) + .is_ok() +} + +/// The notebook folder whose document is `root`, or with `folder` false the lone section +/// `root` names, as the provider `sb_set_provider` registered keeps it; `local` as +/// `sb_library_open` takes it. While the provider can't be reached, a folder listed before +/// opens as last listed, and sections opened before open from their copies; a revoked grant +/// refuses to open and keeps them. +/// +/// # Safety +/// `root` and `cache` are NUL-terminated UTF-8; `error` is null or a place for a string. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_library_provider( + root: *const c_char, + folder: bool, + local: bool, + cache: *const c_char, + error: *mut *mut c_char, +) -> *const Library { + shared( + Library::provider(&string(root), folder, local, Path::new(&string(cache))), + error, + ) +} + +/// # Safety +/// `library` came from `sb_library_open`, `sb_library_server`, `sb_library_hosted` or +/// `sb_library_provider` and is not used again. #[unsafe(no_mangle)] pub unsafe extern "C" fn sb_library_free(library: *const Library) { drop(unsafe { Arc::from_raw(library) }); diff --git a/crates/mobile/src/provider.rs b/crates/mobile/src/provider.rs new file mode 100644 index 0000000000000000000000000000000000000000..67139ddb07a625741739b4422adc38457d98377e --- /dev/null +++ b/crates/mobile/src/provider.rs @@ -0,0 +1,194 @@ +//! The host's document provider behind the C surface, as Android's `ContentResolver` reaches +//! documents a picked folder or file grants: a table of functions the host registers once, +//! naming documents by the ids it chooses (their URIs). + +use crate::string; +use notebook::provider::{Document, Provider}; +use std::{ + collections::HashMap, + ffi::{CString, c_char, c_void}, + fs::File, + io, + os::fd::{AsRawFd, FromRawFd}, + sync::{Mutex, OnceLock}, +}; + +/// Hears one document: its id and name, whether it is a folder, its size and last change +/// (milliseconds since 1970) or -1 where the provider gives none, and whether the grant and +/// the provider allow writing it. +pub type Found = extern "C" fn( + context: *mut c_void, + id: *const c_char, + name: *const c_char, + directory: bool, + size: i64, + modified: i64, + writable: bool, +); + +/// The host's provider, `sb_provider` in `snowbound.h`, which defines its calls' statuses and +/// contract. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct Table { + /// Calls `found` once for the document `id`. + pub document: extern "C" fn(id: *const c_char, found: Found, context: *mut c_void) -> i32, + /// Calls `found` for each document in the folder `id`. + pub children: extern "C" fn(id: *const c_char, found: Found, context: *mut c_void) -> i32, + /// Opens `id` in `ContentResolver` mode `r` or `rw`, setting `fd` to a descriptor the + /// caller owns and closes, and `handle` to what `close` finishes. + pub open: extern "C" fn( + id: *const c_char, + mode: *const c_char, + fd: *mut i32, + handle: *mut u64, + ) -> i32, + /// Finishes an open once its descriptor is closed: the provider's reported error, if any + /// (`ParcelFileDescriptor.checkError`), then the host's close. + pub close: extern "C" fn(handle: u64) -> i32, + /// Renames `id` to `name`, calling `renamed` with its new id. + pub rename: extern "C" fn( + id: *const c_char, + name: *const c_char, + renamed: extern "C" fn(*mut c_void, *const c_char), + context: *mut c_void, + ) -> i32, +} + +static TABLE: OnceLock = OnceLock::new(); + +/// Opens provider notebooks through `table` from now on; set once, before any opens. +/// +/// # Safety +/// Every function in `table` keeps `snowbound.h`'s contract for `sb_provider`: in particular a +/// descriptor `open` hands over is the caller's alone to use and close. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn sb_set_provider(table: &Table) { + let _ = TABLE.set(*table); +} + +fn table() -> io::Result<&'static Table> { + TABLE.get().ok_or_else(|| { + io::Error::new( + io::ErrorKind::Unsupported, + "No document provider is registered", + ) + }) +} + +fn status(code: i32) -> io::Result<()> { + let (kind, message) = match code { + 0 => return Ok(()), + 1 => ( + io::ErrorKind::NotConnected, + "The app keeping the notebook can’t be reached", + ), + 2 => ( + io::ErrorKind::PermissionDenied, + "Snowbound no longer has access to the notebook", + ), + 3 => (io::ErrorKind::NotFound, "The notebook’s file is gone"), + _ => (io::ErrorKind::Other, "The app keeping the notebook failed"), + }; + Err(io::Error::new(kind, message)) +} + +fn described( + call: extern "C" fn(*const c_char, Found, *mut c_void) -> i32, + id: &str, +) -> io::Result> { + extern "C" fn found( + context: *mut c_void, + id: *const c_char, + name: *const c_char, + directory: bool, + size: i64, + modified: i64, + writable: bool, + ) { + // SAFETY: `context` is the list below, alive for the host's call. + let documents = unsafe { &mut *context.cast::>() }; + documents.push(Document { + id: string(id), + name: string(name), + directory, + size: u64::try_from(size).ok(), + modified: u64::try_from(modified).ok(), + writable, + }); + } + let id = CString::new(id)?; + let mut documents: Vec = Vec::new(); + status(call(id.as_ptr(), found, (&raw mut documents).cast()))?; + Ok(documents) +} + +/// The registered table as a `Provider`, with the handle of each descriptor it opened. +#[derive(Default)] +pub(crate) struct Host(Mutex>); + +impl Provider for Host { + fn document(&self, id: &str) -> io::Result { + described(table()?.document, id)? + .pop() + .ok_or_else(|| io::ErrorKind::NotFound.into()) + } + + fn children(&self, id: &str) -> io::Result> { + described(table()?.children, id) + } + + fn open(&self, id: &str, mode: &str) -> io::Result { + let (id, mode) = (CString::new(id)?, CString::new(mode)?); + let (mut fd, mut handle) = (-1, 0); + let table = table()?; + status((table.open)( + id.as_ptr(), + mode.as_ptr(), + &mut fd, + &mut handle, + ))?; + if fd < 0 { + status((table.close)(handle))?; + return Err(io::Error::other("The provider gave no descriptor")); + } + // SAFETY: the host hands over a descriptor only this file uses and closes. + let file = unsafe { File::from_raw_fd(fd) }; + self.0 + .lock() + .unwrap_or_else(|error| error.into_inner()) + .insert(fd, handle); + Ok(file) + } + + /// The descriptor closes first, so a write's end reaches the provider before the host + /// asks for its verdict. + fn close(&self, file: File) -> io::Result<()> { + let handle = self + .0 + .lock() + .unwrap_or_else(|error| error.into_inner()) + .remove(&file.as_raw_fd()); + drop(file); + match handle { + Some(handle) => status((table()?.close)(handle)), + None => Ok(()), + } + } + + fn rename(&self, id: &str, name: &str) -> io::Result { + extern "C" fn renamed(context: *mut c_void, id: *const c_char) { + // SAFETY: `context` is the id below, alive for the host's call. + unsafe { *context.cast::>() = Some(string(id)) }; + } + let (id, name) = (CString::new(id)?, CString::new(name)?); + let mut new: Option = None; + status((table()?.rename)( + id.as_ptr(), + name.as_ptr(), + renamed, + (&raw mut new).cast(), + ))?; + new.ok_or_else(|| io::Error::other("The provider gave no new id")) + } +} diff --git a/crates/mobile/src/tests.rs b/crates/mobile/src/tests.rs index 282826da36a458ece5149638ab7ae7060b9f1378..d33418b1f7c53b22879e0bc17c09cbf1296e0d31 100644 --- a/crates/mobile/src/tests.rs +++ b/crates/mobile/src/tests.rs @@ -3596,3 +3596,301 @@ fn reader_deferred_reload_preserves_rich_composition_after_real_local_write_fail assert_eq!(page.color, Some(0x0099_8877)); } } + +/// A document provider over real folders, documents named by their paths, failing as the code +/// `fail` set for a folder says. +mod provider_fake { + use crate::provider::{Found, Table, sb_set_provider}; + use crate::string; + use std::{ + ffi::{CString, c_char, c_void}, + os::fd::IntoRawFd, + path::{Path, PathBuf}, + sync::Mutex, + }; + + static FAILING: Mutex> = Mutex::new(Vec::new()); + + /// Has every call on documents in `root` fail with `code`, or none. + pub(super) fn fail(root: &Path, code: Option) { + let mut failing = FAILING.lock().unwrap(); + failing.retain(|(failed, _)| failed != root); + failing.extend(code.map(|code| (root.to_owned(), code))); + } + + fn failing(id: *const c_char) -> Result { + let path = PathBuf::from(string(id)); + let failing = FAILING.lock().unwrap(); + match failing.iter().find(|(root, _)| path.starts_with(root)) { + Some((_, code)) => Err(*code), + None => Ok(path), + } + } + + fn describe(path: &Path, found: Found, context: *mut c_void) -> i32 { + let Ok(metadata) = std::fs::metadata(path) else { + return 3; + }; + let id = CString::new(path.to_str().unwrap()).unwrap(); + let name = CString::new(path.file_name().unwrap().to_str().unwrap()).unwrap(); + let modified = metadata.modified().unwrap(); + let modified = modified.duration_since(std::time::UNIX_EPOCH).unwrap(); + let (size, modified) = (metadata.len() as i64, modified.as_millis() as i64); + found( + context, + id.as_ptr(), + name.as_ptr(), + metadata.is_dir(), + size, + modified, + true, + ); + 0 + } + + extern "C" fn document(id: *const c_char, found: Found, context: *mut c_void) -> i32 { + failing(id).map_or_else(|code| code, |path| describe(&path, found, context)) + } + + extern "C" fn children(id: *const c_char, found: Found, context: *mut c_void) -> i32 { + let path = match failing(id) { + Ok(path) => path, + Err(code) => return code, + }; + let Ok(entries) = std::fs::read_dir(path) else { + return 3; + }; + for entry in entries { + describe(&entry.unwrap().path(), found, context); + } + 0 + } + + extern "C" fn open( + id: *const c_char, + mode: *const c_char, + fd: *mut i32, + handle: *mut u64, + ) -> i32 { + let path = match failing(id) { + Ok(path) => path, + Err(code) => return code, + }; + let write = string(mode) == "rw"; + match std::fs::File::options().read(true).write(write).open(path) { + Ok(file) => { + // SAFETY: the caller passes places for one descriptor and one handle. + unsafe { + fd.write(file.into_raw_fd()); + handle.write(1); + } + 0 + } + Err(_) => 3, + } + } + + extern "C" fn close(handle: u64) -> i32 { + assert_eq!(handle, 1); + 0 + } + + extern "C" fn rename( + id: *const c_char, + name: *const c_char, + renamed: extern "C" fn(*mut c_void, *const c_char), + context: *mut c_void, + ) -> i32 { + let path = match failing(id) { + Ok(path) => path, + Err(code) => return code, + }; + let new = path.with_file_name(string(name)); + if std::fs::rename(&path, &new).is_err() { + return 3; + } + let new = CString::new(new.to_str().unwrap()).unwrap(); + renamed(context, new.as_ptr()); + 0 + } + + pub(super) fn register() { + // SAFETY: every function keeps the header's contract; `open` hands over its own file. + unsafe { + sb_set_provider(&Table { + document, + children, + open, + close, + rename, + }) + }; + } +} + +/// The library `sb_library_provider` opens on `root`, or its error. +fn provided(root: &Path, folder: bool, cache: &Path) -> std::result::Result, String> { + provider_fake::register(); + let (root, cache) = ( + CString::new(root.to_str().unwrap()).unwrap(), + CString::new(cache.to_str().unwrap()).unwrap(), + ); + let mut error = std::ptr::null_mut(); + // SAFETY: both strings are NUL-terminated and `error` a place for one. + let library = unsafe { + library::sb_library_provider(root.as_ptr(), folder, true, cache.as_ptr(), &mut error) + }; + if library.is_null() { + let message = string(error); + // SAFETY: `error` came from the call above. + unsafe { sb_string_free(error) }; + return Err(message); + } + // SAFETY: a library the call above made, freed when the Arc drops (`sb_library_free`). + Ok(unsafe { Arc::from_raw(library) }) +} + +/// Types `text` before "Collapsed parent" on Paragraph controls, returning the page. +fn typed(section: &Section, text: &str) -> ExGuid { + let mut canvas = canvas(section, "Paragraph controls"); + focus(&mut canvas, "Collapsed parent"); + canvas.insert(text.into()).unwrap(); + section + .shared + .apply(canvas.edit().unwrap().unwrap()) + .unwrap(); + section.shared.section.replica().written().unwrap(); + canvas.space +} + +fn closed(section: Section) { + let shared = Arc::clone(§ion.shared); + drop(section); + Arc::into_inner(shared).unwrap().section.close().unwrap(); +} + +fn flushed(library: &Library, seconds: f64) -> bool { + // SAFETY: a null error is allowed. + unsafe { library::sb_library_flush(library, seconds, std::ptr::null_mut()) } +} + +#[test] +fn a_provider_folder_opens_through_the_c_surface_and_publishes_in_place() { + let (directory, root) = copy("m6/native-features-01/notebook"); + let library = provided(&root, true, &directory.path().join("cache")).unwrap(); + let tabs = library.tabs().unwrap(); + assert!( + tabs.iter() + .any(|tab| tab.path == "Features.one" && tab.readable) + ); + let section = open(&library, "Features.one"); + let space = typed(§ion, "Provided "); + assert!(section.flush(Duration::from_secs(20))); + assert!(stored_text(&root.join("Features.one"), space).contains("Provided ")); + closed(section); +} + +#[test] +fn a_provider_notebook_cold_opens_offline_as_last_listed_with_its_queued_edits() { + let (directory, root) = copy("m6/native-features-01/notebook"); + let cache = directory.path().join("cache"); + let library = provided(&root, true, &cache).unwrap(); + let listed: Vec = library + .tabs() + .unwrap() + .into_iter() + .map(|tab| tab.path) + .collect(); + let section = open(&library, "Features.one"); + provider_fake::fail(&root, Some(1)); + let space = typed(§ion, "Offline "); + assert!(!flushed(&library, 0.5)); + closed(section); + drop(library); + + let library = provided(&root, true, &cache).unwrap(); + let tabs: Vec = library + .tabs() + .unwrap() + .into_iter() + .map(|tab| tab.path) + .collect(); + assert_eq!(tabs, listed); + let section = open(&library, "Features.one"); + let (page, _) = section.shared.page(space).unwrap(); + assert!(canvas::search::page_text(&page).contains("Offline ")); + assert_eq!(section.shared.section.pending().unwrap().len(), 1); + assert!(eventually(|| library.sync_status().iter().any(|sync| { + sync.path == "Features.one" && sync.state == SyncState::NotConnected as u8 + }))); + assert!(!stored_text(&root.join("Features.one"), space).contains("Offline ")); + provider_fake::fail(&root, None); + sb_library_sync_now(&library); + assert!(section.flush(Duration::from_secs(20))); + assert!(stored_text(&root.join("Features.one"), space).contains("Offline ")); + closed(section); +} + +#[test] +fn a_revoked_grant_refuses_to_open_and_keeps_the_queued_edits() { + let (directory, root) = copy("m6/native-features-01/notebook"); + let cache = directory.path().join("cache"); + let library = provided(&root, true, &cache).unwrap(); + let section = open(&library, "Features.one"); + provider_fake::fail(&root, Some(2)); + let space = typed(§ion, "Kept "); + closed(section); + drop(library); + let refused = provided(&root, true, &cache).err().unwrap(); + assert!(refused.contains("no longer has access"), "{refused}"); + assert!(replicas(&directory) > 0); + + provider_fake::fail(&root, None); + let library = provided(&root, true, &cache).unwrap(); + let section = open(&library, "Features.one"); + assert_eq!(section.shared.section.pending().unwrap().len(), 1); + assert!(section.flush(Duration::from_secs(20))); + assert!(stored_text(&root.join("Features.one"), space).contains("Kept ")); + closed(section); +} + +#[test] +fn finishing_keeps_offline_copies_and_close_notebook_deletes_them() { + let (directory, root) = copy("m6/native-features-01/notebook"); + let cache = directory.path().join("cache"); + let library = provided(&root, true, &cache).unwrap(); + let readable = library + .tabs() + .unwrap() + .iter() + .filter(|tab| tab.readable) + .count(); + assert!(eventually(|| replicas(&directory) == readable)); + assert!(flushed(&library, 10.0)); + drop(library); + std::thread::sleep(Duration::from_millis(200)); + assert_eq!(replicas(&directory), readable); + + let library = provided(&root, true, &cache).unwrap(); + // SAFETY: a null error is allowed. + assert!(unsafe { library::sb_library_close(&library, 10.0, std::ptr::null_mut()) }); + drop(library); + assert!(eventually(|| replicas(&directory) == 0)); +} + +#[test] +fn a_lone_provider_document_opens_and_publishes() { + let (directory, file) = copy("media-edit/candidate/Features.one"); + let library = provided(&file, false, &directory.path().join("cache")).unwrap(); + let tabs = library.tabs().unwrap(); + assert_eq!( + (tabs[0].name.as_str(), tabs[0].path.as_str()), + ("Features", "") + ); + let section = open(&library, ""); + let space = typed(§ion, "Alone "); + // As finishing flushes: the library, its open section and no background. + assert!(flushed(&library, 20.0)); + assert!(stored_text(&file, space).contains("Alone ")); + closed(section); +} -- 2.54.0