authorclover caruso <git@paperclover.net> 2026-10-15 10:48:28-07:00
committerclover caruso <git@paperclover.net> 2026-10-15 11:15:01-07:00
log201763c9e037596207365ab10fd3ed5a6b9a6ff4
tree487f88ca060c1000622fb015bb5f693e5c1909a0
parentffaff48d67462562d069edde11886b41bb411e8c
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

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

6 files changed, 670 insertions(+), 32 deletions(-)

apps/android/jni/snowbound.c+6-4
...@@ -354,13 +354,15 @@ JNIEXPORT void JNICALL JNI(freeView)(JNIEnv *env, jclass cls, jlong view) {...@@ -354,13 +354,15 @@ JNIEXPORT void JNICALL JNI(freeView)(JNIEnv *env, jclass cls, jlong view) {
354 sb_view_free((View *)(intptr_t)view);354 sb_view_free((View *)(intptr_t)view);
355}355}
356356
357// Frees the section, then publishes the notebook's edits and lets go of it: false, the edits357// Publishes the notebook's edits, the open section's included, then frees the section and lets go
358// kept to publish when it next opens, when they don't publish within `seconds`.358// of the notebook, keeping its offline copies: false, the edits kept to publish when it next
359// opens, when they don't publish within `seconds`. Finishing the activity is not Close Notebook
360// (sb_library_close).
359JNIEXPORT jboolean JNICALL JNI(close)(JNIEnv *env, jclass cls, jlong library, jlong section, jdouble seconds) {361JNIEXPORT jboolean JNICALL JNI(close)(JNIEnv *env, jclass cls, jlong library, jlong section, jdouble seconds) {
360 (void)env, (void)cls;362 (void)env, (void)cls;
361 sb_section_free((Section *)(intptr_t)section);
362 char *error = NULL;363 char *error = NULL;
363 bool closed = sb_library_close((const Library *)(intptr_t)library, seconds, &error);364 bool closed = sb_library_flush((const Library *)(intptr_t)library, seconds, &error);
365 sb_section_free((Section *)(intptr_t)section);
364 if (error) {366 if (error) {
365 fprintf(stderr, "snowbound: %s\n", error);367 fprintf(stderr, "snowbound: %s\n", error);
366 sb_string_free(error);368 sb_string_free(error);
crates/mobile/include/snowbound.h+25
...@@ -27,7 +27,30 @@ char *sb_tags(void);...@@ -27,7 +27,30 @@ char *sb_tags(void);
27char *sb_pens(uint32_t section);27char *sb_pens(uint32_t section);
28bool sb_tag_icon(uint16_t shape, bool checked, uint32_t pixels, uint8_t *rgba);28bool sb_tag_icon(uint16_t shape, bool checked, uint32_t pixels, uint8_t *rgba);
2929
30// A document provider, as Android's ContentResolver reaches picked documents by URI. Each call
31// returns 0, or 1 while the provider can't be reached, 2 once the grant is revoked, 3 for a
32// document gone, anything else for another failure; size and modified (ms since 1970) are -1
33// where unknown. Every field is required. Calls come concurrently from the caller's thread, the
34// background sync thread and each section's worker, and the host attaches those threads to its
35// runtime (the JVM) itself; callbacks run only within the call that takes them. Ids and names
36// passed either way are non-null.
37typedef void (*sb_document_found)(void *context, const char *id, const char *name, bool directory, int64_t size,
38 int64_t modified, bool writable);
39typedef struct sb_provider {
40 int32_t (*document)(const char *id, sb_document_found found, void *context);
41 int32_t (*children)(const char *id, sb_document_found found, void *context);
42 // mode is "r" or "rw"; fd is a duplicate Snowbound owns and closes, handle the descriptor
43 // the host keeps open: detachFd would end the channel checkError reads. A nonzero status
44 // sets neither; a negative fd with status 0 has its handle closed.
45 int32_t (*open)(const char *id, const char *mode, int32_t *fd, uint64_t *handle);
46 // Once fd is closed: the provider's error (checkError), then the host's close.
47 int32_t (*close)(uint64_t handle);
48 int32_t (*rename)(const char *id, const char *name, void (*renamed)(void *context, const char *id), void *context);
49} sb_provider;
50void sb_set_provider(const sb_provider *provider);
51
30const Library *sb_library_open(const char *path, const char *cache, bool local, char **error);52const Library *sb_library_open(const char *path, const char *cache, bool local, char **error);
53const Library *sb_library_provider(const char *root, bool folder, bool local, const char *cache, char **error);
31const Library *sb_library_server(const char *address, const char *share, const char *user, const char *password,54const Library *sb_library_server(const char *address, const char *share, const char *user, const char *password,
32 const char *domain, const char *root, const char *cache, char **error);55 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.56// 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);...@@ -57,6 +80,8 @@ char *sb_library_sync_status(const Library *library);
57void sb_library_set_offline(const Library *library, bool offline);80void sb_library_set_offline(const Library *library, bool offline);
58void sb_library_touched(const Library *library, const char *path);81void sb_library_touched(const Library *library, const char *path);
59void sb_library_sync_now(const Library *library);82void sb_library_sync_now(const Library *library);
83// Publishes, keeping offline copies; sb_library_close is Close Notebook and deletes them.
84bool sb_library_flush(const Library *library, double seconds, char **error);
60bool sb_library_close(const Library *library, double seconds, char **error);85bool sb_library_close(const Library *library, double seconds, char **error);
61void sb_library_free(const Library *library);86void sb_library_free(const Library *library);
6287
crates/mobile/src/lib.rs+1
...@@ -9,6 +9,7 @@...@@ -9,6 +9,7 @@
99
10mod crash;10mod crash;
11mod library;11mod library;
12mod provider;
12mod recording;13mod recording;
13#[cfg(target_os = "ios")]14#[cfg(target_os = "ios")]
14mod spell;15mod spell;
crates/mobile/src/library.rs+146-28
...@@ -8,6 +8,7 @@ use notebook::{...@@ -8,6 +8,7 @@ use notebook::{
8 Remote, Replica,8 Remote, Replica,
9 discover::{Folder, Reason, SectionState},9 discover::{Folder, Reason, SectionState},
10 live::{self, Hello, Reach, share::Guest},10 live::{self, Hello, Reach, share::Guest},
11 provider::Documents,
11 session::{self, Background, Event, Known, Notebook, SyncStatus},12 session::{self, Background, Event, Known, Notebook, SyncStatus},
12 smb::{Client, Credentials},13 smb::{Client, Credentials},
13};14};
...@@ -223,6 +224,11 @@ enum Place {...@@ -223,6 +224,11 @@ enum Place {
223 root: String,224 root: String,
224 },225 },
225 Hosted(Arc<Guest>),226 Hosted(Arc<Guest>),
227 /// A notebook folder a document provider keeps.
228 Provider(Arc<Documents>),
229 /// A section a document provider keeps, opened on its own, with its name when it opened:
230 /// none while the provider can't be reached, the host keeping the name it was picked by.
231 Document(Arc<Documents>, Option<String>),
226}232}
227233
228#[derive(serde::Serialize, serde::Deserialize)]234#[derive(serde::Serialize, serde::Deserialize)]
...@@ -502,6 +508,43 @@ impl Library {...@@ -502,6 +508,43 @@ impl Library {
502 })508 })
503 }509 }
504510
511 /// The notebook folder, or with `folder` false the lone section, whose document `root` the
512 /// registered provider keeps (`sb_set_provider`). A provider `local` to this device
513 /// publishes each edit at once; any other waits for a pause in typing. While the provider
514 /// can't be reached, the folder opens as last listed and sections from their offline copies.
515 fn provider(root: &str, folder: bool, local: bool, cache: &Path) -> Result<Self> {
516 let host = crate::provider::Host::default();
517 let name = (!folder)
518 .then(|| {
519 Some(
520 notebook::provider::Provider::document(&host, root)
521 .ok()?
522 .name,
523 )
524 })
525 .flatten();
526 let documents = Arc::new(Documents::new(host, root));
527 let (notebook, place, background) = if folder {
528 let mut notebook = Notebook::open_provider(Arc::clone(&documents), cache)?;
529 let background = Background::provider(Arc::clone(&documents), sync_woken)?;
530 background.watch(notebook.replicas());
531 (Some(notebook), Place::Provider(documents), Some(background))
532 } else {
533 (None, Place::Document(documents, name), None)
534 };
535 Ok(Self {
536 notebook: Mutex::new(notebook),
537 place,
538 cache: cache.to_owned(),
539 index: Mutex::default(),
540 background,
541 open: Mutex::default(),
542 offline: AtomicBool::new(false),
543 keys: Default::default(),
544 pause: if local { Duration::ZERO } else { PAUSE },
545 })
546 }
547
505 /// Has the background sync a share notebook's sections, `files` as `Listed` keeps them.548 /// Has the background sync a share notebook's sections, `files` as `Listed` keeps them.
506 fn watch(&self, files: &BTreeMap<String, String>) {549 fn watch(&self, files: &BTreeMap<String, String>) {
507 let (Some(background), Some(replicas)) = (&self.background, self.share_replicas()) else {550 let (Some(background), Some(replicas)) = (&self.background, self.share_replicas()) else {
...@@ -619,11 +662,17 @@ impl Library {...@@ -619,11 +662,17 @@ impl Library {
619662
620 /// The notebook's sections, read again.663 /// The notebook's sections, read again.
621 pub(crate) fn tabs(&self) -> Result<Vec<Tab>> {664 pub(crate) fn tabs(&self) -> Result<Vec<Tab>> {
622 if let Place::File(file) = &self.place {665 let lone = match &self.place {
623 let path = file.to_string_lossy().into_owned();666 Place::File(file) => Some((stem(&file.to_string_lossy()), file.to_string_lossy())),
667 Place::Document(_, name) => {
668 Some((stem(name.as_deref().unwrap_or_default()), "".into()))
669 }
670 _ => None,
671 };
672 if let Some((name, path)) = lone {
624 return Ok(vec![Tab {673 return Ok(vec![Tab {
625 name: stem(&path),674 name,
626 path,675 path: path.into_owned(),
627 group: String::new(),676 group: String::new(),
628 color: srgb(SECTION_COLOR),677 color: srgb(SECTION_COLOR),
629 readable: true,678 readable: true,
...@@ -642,7 +691,7 @@ impl Library {...@@ -642,7 +691,7 @@ impl Library {
642 if matches!(self.place, Place::Share { .. }) {691 if matches!(self.place, Place::Share { .. }) {
643 files(notebook.catalog(), &mut listed.files);692 files(notebook.catalog(), &mut listed.files);
644 }693 }
645 if let (Place::Folder(_) | Place::Hosted(_), Some(background)) =694 if let (Place::Folder(_) | Place::Hosted(_) | Place::Provider(_), Some(background)) =
646 (&self.place, &self.background)695 (&self.place, &self.background)
647 {696 {
648 background.watch(notebook.replicas());697 background.watch(notebook.replicas());
...@@ -698,7 +747,7 @@ impl Library {...@@ -698,7 +747,7 @@ impl Library {
698 fn section_once(747 fn section_once(
699 &self,748 &self,
700 path: &str,749 path: &str,
701 notify: impl Fn() + Send + 'static,750 notify: impl Fn() + Send + Sync + 'static,
702 ) -> Result<session::Section> {751 ) -> Result<session::Section> {
703 let offline = self.offline.load(Ordering::Relaxed);752 let offline = self.offline.load(Ordering::Relaxed);
704 match &self.place {753 match &self.place {
...@@ -759,26 +808,43 @@ impl Library {...@@ -759,26 +808,43 @@ impl Library {
759 notify,808 notify,
760 )?)809 )?)
761 }810 }
762 Place::Hosted(guest) => {811 Place::Hosted(guest) => Ok(session::Section::resume_hosted(
763 let notebook = self.notebook();812 path.to_owned(),
764 let notebook = notebook813 self.served_replica(path)?,
765 .as_ref()814 Arc::clone(guest),
766 .ok_or_else(|| io::Error::from(io::ErrorKind::NotConnected))?;815 offline,
767 let cache = notebook.replica_path(path)?;816 notify,
768 std::fs::create_dir_all(cache.parent().unwrap_or(&self.cache))?;817 )?),
769 let key = self.unlocked().get(path).cloned();818 Place::Provider(documents) => Ok(session::Section::resume_provider(
770 let replica =819 path.to_owned(),
771 Replica::open_or_create(&cache, key.as_ref(), || notebook.read_section(path))?;820 self.served_replica(path)?,
772 Ok(session::Section::resume_hosted(821 Arc::clone(documents),
773 path.to_owned(),822 offline,
774 replica,823 notify,
775 Arc::clone(guest),824 )?),
776 offline,825 Place::Document(documents, _) => Ok(session::Section::open_provider(
777 notify,826 Arc::clone(documents),
778 )?)827 &self.cache,
779 }828 offline,
829 notify,
830 )?),
780 }831 }
781 }832 }
833
834 /// The replica of the section at catalog `path` of a notebook another program serves,
835 /// made from the section where there is none.
836 fn served_replica(&self, path: &str) -> Result<Replica> {
837 let notebook = self.notebook();
838 let notebook = notebook
839 .as_ref()
840 .ok_or_else(|| io::Error::from(io::ErrorKind::NotConnected))?;
841 let cache = notebook.replica_path(path)?;
842 std::fs::create_dir_all(cache.parent().unwrap_or(&self.cache))?;
843 let key = self.unlocked().get(path).cloned();
844 Ok(Replica::open_or_create(&cache, key.as_ref(), || {
845 notebook.read_section(path)
846 })?)
847 }
782}848}
783849
784/// What saving has come to, as the page shows it.850/// What saving has come to, as the page shows it.
...@@ -1039,7 +1105,7 @@ impl Section {...@@ -1039,7 +1105,7 @@ impl Section {
1039 let listed = section.pages()?;1105 let listed = section.pages()?;
1040 let mut ops = vec![Op::Section(SectionOp::Delete(vec![space]))];1106 let mut ops = vec![Op::Section(SectionOp::Delete(vec![space]))];
1041 if listed.iter().any(|(page, ..)| *page == space) {1107 if listed.iter().any(|(page, ..)| *page == space) {
1042 if !matches!(self.library.place, Place::File(_)) {1108 if !matches!(self.library.place, Place::File(_) | Place::Document(..)) {
1043 let page = section.page(space)?;1109 let page = section.page(space)?;
1044 let author = self.shared.author();1110 let author = self.shared.author();
1045 self.library.with_notebook(true, |notebook| {1111 self.library.with_notebook(true, |notebook| {
...@@ -1280,6 +1346,16 @@ impl Library {...@@ -1280,6 +1346,16 @@ impl Library {
1280 /// notebook as OneNote closes one: nothing syncs from then on, and each replica holding1346 /// notebook as OneNote closes one: nothing syncs from then on, and each replica holding
1281 /// nothing unpublished is deleted once no session holds it. An error leaves it syncing.1347 /// nothing unpublished is deleted once no session holds it. An error leaves it syncing.
1282 pub(crate) fn close(&self, limit: Duration) -> Result<()> {1348 pub(crate) fn close(&self, limit: Duration) -> Result<()> {
1349 self.flush(limit)?;
1350 if let Some(background) = &self.background {
1351 background.discard();
1352 }
1353 Ok(())
1354 }
1355
1356 /// Publishes every edit the notebook's replicas hold within `limit`, keeping them and the
1357 /// offline copies.
1358 pub(crate) fn flush(&self, limit: Duration) -> Result<()> {
1283 const WAITING: &str = "Some changes haven’t been saved to the notebook yet.";1359 const WAITING: &str = "Some changes haven’t been saved to the notebook yet.";
1284 let deadline = Instant::now() + limit;1360 let deadline = Instant::now() + limit;
1285 let open = self.open_sections();1361 let open = self.open_sections();
...@@ -1338,7 +1414,6 @@ impl Library {...@@ -1338,7 +1414,6 @@ impl Library {
1338 }1414 }
1339 std::thread::sleep(Duration::from_millis(50));1415 std::thread::sleep(Duration::from_millis(50));
1340 }1416 }
1341 background.discard();
1342 Ok(())1417 Ok(())
1343 }1418 }
13441419
...@@ -1542,7 +1617,9 @@ impl Library {...@@ -1542,7 +1617,9 @@ impl Library {
1542 }1617 }
1543 Place::Folder(root) => local(root.join(path))?,1618 Place::Folder(root) => local(root.join(path))?,
1544 Place::File(file) => local(file.clone())?,1619 Place::File(file) => local(file.clone())?,
1545 Place::Hosted(_) => {1620 // Indexed from its session while it is open.
1621 Place::Document(..) => return Ok(()),
1622 Place::Hosted(_) | Place::Provider(_) => {
1546 let cache = self1623 let cache = self
1547 .notebook()1624 .notebook()
1548 .as_ref()1625 .as_ref()
...@@ -2127,8 +2204,49 @@ pub unsafe extern "C" fn sb_library_close(...@@ -2127,8 +2204,49 @@ pub unsafe extern "C" fn sb_library_close(
2127 .is_ok()2204 .is_ok()
2128}2205}
21292206
2207/// Publishes every edit the notebook holds within `seconds`, keeping its offline copies, as an
2208/// app does that stops while the notebook stays open: false, with `error` set, while an edit
2209/// is still unpublished. Close Notebook is `sb_library_close`.
2210///
2211/// # Safety
2212/// `error` is null or a place for a string.
2213#[unsafe(no_mangle)]
2214pub unsafe extern "C" fn sb_library_flush(
2215 library: &Library,
2216 seconds: f64,
2217 error: *mut *mut c_char,
2218) -> bool {
2219 library
2220 .flush(Duration::from_secs_f64(seconds))
2221 .map_err(|cause| failed(cause, error))
2222 .is_ok()
2223}
2224
2225/// The notebook folder whose document is `root`, or with `folder` false the lone section
2226/// `root` names, as the provider `sb_set_provider` registered keeps it; `local` as
2227/// `sb_library_open` takes it. While the provider can't be reached, a folder listed before
2228/// opens as last listed, and sections opened before open from their copies; a revoked grant
2229/// refuses to open and keeps them.
2230///
2231/// # Safety
2232/// `root` and `cache` are NUL-terminated UTF-8; `error` is null or a place for a string.
2233#[unsafe(no_mangle)]
2234pub unsafe extern "C" fn sb_library_provider(
2235 root: *const c_char,
2236 folder: bool,
2237 local: bool,
2238 cache: *const c_char,
2239 error: *mut *mut c_char,
2240) -> *const Library {
2241 shared(
2242 Library::provider(&string(root), folder, local, Path::new(&string(cache))),
2243 error,
2244 )
2245}
2246
2130/// # Safety2247/// # Safety
2131/// `library` came from `sb_library_open` or `sb_library_server` and is not used again.2248/// `library` came from `sb_library_open`, `sb_library_server`, `sb_library_hosted` or
2249/// `sb_library_provider` and is not used again.
2132#[unsafe(no_mangle)]2250#[unsafe(no_mangle)]
2133pub unsafe extern "C" fn sb_library_free(library: *const Library) {2251pub unsafe extern "C" fn sb_library_free(library: *const Library) {
2134 drop(unsafe { Arc::from_raw(library) });2252 drop(unsafe { Arc::from_raw(library) });
crates/mobile/src/provider.rs created+194
...@@ -0,0 +1,194 @@
1//! The host's document provider behind the C surface, as Android's `ContentResolver` reaches
2//! documents a picked folder or file grants: a table of functions the host registers once,
3//! naming documents by the ids it chooses (their URIs).
4
5use crate::string;
6use notebook::provider::{Document, Provider};
7use std::{
8 collections::HashMap,
9 ffi::{CString, c_char, c_void},
10 fs::File,
11 io,
12 os::fd::{AsRawFd, FromRawFd},
13 sync::{Mutex, OnceLock},
14};
15
16/// Hears one document: its id and name, whether it is a folder, its size and last change
17/// (milliseconds since 1970) or -1 where the provider gives none, and whether the grant and
18/// the provider allow writing it.
19pub type Found = extern "C" fn(
20 context: *mut c_void,
21 id: *const c_char,
22 name: *const c_char,
23 directory: bool,
24 size: i64,
25 modified: i64,
26 writable: bool,
27);
28
29/// The host's provider, `sb_provider` in `snowbound.h`, which defines its calls' statuses and
30/// contract.
31#[repr(C)]
32#[derive(Clone, Copy)]
33pub struct Table {
34 /// Calls `found` once for the document `id`.
35 pub document: extern "C" fn(id: *const c_char, found: Found, context: *mut c_void) -> i32,
36 /// Calls `found` for each document in the folder `id`.
37 pub children: extern "C" fn(id: *const c_char, found: Found, context: *mut c_void) -> i32,
38 /// Opens `id` in `ContentResolver` mode `r` or `rw`, setting `fd` to a descriptor the
39 /// caller owns and closes, and `handle` to what `close` finishes.
40 pub open: extern "C" fn(
41 id: *const c_char,
42 mode: *const c_char,
43 fd: *mut i32,
44 handle: *mut u64,
45 ) -> i32,
46 /// Finishes an open once its descriptor is closed: the provider's reported error, if any
47 /// (`ParcelFileDescriptor.checkError`), then the host's close.
48 pub close: extern "C" fn(handle: u64) -> i32,
49 /// Renames `id` to `name`, calling `renamed` with its new id.
50 pub rename: extern "C" fn(
51 id: *const c_char,
52 name: *const c_char,
53 renamed: extern "C" fn(*mut c_void, *const c_char),
54 context: *mut c_void,
55 ) -> i32,
56}
57
58static TABLE: OnceLock<Table> = OnceLock::new();
59
60/// Opens provider notebooks through `table` from now on; set once, before any opens.
61///
62/// # Safety
63/// Every function in `table` keeps `snowbound.h`'s contract for `sb_provider`: in particular a
64/// descriptor `open` hands over is the caller's alone to use and close.
65#[unsafe(no_mangle)]
66pub unsafe extern "C" fn sb_set_provider(table: &Table) {
67 let _ = TABLE.set(*table);
68}
69
70fn table() -> io::Result<&'static Table> {
71 TABLE.get().ok_or_else(|| {
72 io::Error::new(
73 io::ErrorKind::Unsupported,
74 "No document provider is registered",
75 )
76 })
77}
78
79fn status(code: i32) -> io::Result<()> {
80 let (kind, message) = match code {
81 0 => return Ok(()),
82 1 => (
83 io::ErrorKind::NotConnected,
84 "The app keeping the notebook can’t be reached",
85 ),
86 2 => (
87 io::ErrorKind::PermissionDenied,
88 "Snowbound no longer has access to the notebook",
89 ),
90 3 => (io::ErrorKind::NotFound, "The notebook’s file is gone"),
91 _ => (io::ErrorKind::Other, "The app keeping the notebook failed"),
92 };
93 Err(io::Error::new(kind, message))
94}
95
96fn described(
97 call: extern "C" fn(*const c_char, Found, *mut c_void) -> i32,
98 id: &str,
99) -> io::Result<Vec<Document>> {
100 extern "C" fn found(
101 context: *mut c_void,
102 id: *const c_char,
103 name: *const c_char,
104 directory: bool,
105 size: i64,
106 modified: i64,
107 writable: bool,
108 ) {
109 // SAFETY: `context` is the list below, alive for the host's call.
110 let documents = unsafe { &mut *context.cast::<Vec<Document>>() };
111 documents.push(Document {
112 id: string(id),
113 name: string(name),
114 directory,
115 size: u64::try_from(size).ok(),
116 modified: u64::try_from(modified).ok(),
117 writable,
118 });
119 }
120 let id = CString::new(id)?;
121 let mut documents: Vec<Document> = Vec::new();
122 status(call(id.as_ptr(), found, (&raw mut documents).cast()))?;
123 Ok(documents)
124}
125
126/// The registered table as a `Provider`, with the handle of each descriptor it opened.
127#[derive(Default)]
128pub(crate) struct Host(Mutex<HashMap<i32, u64>>);
129
130impl Provider for Host {
131 fn document(&self, id: &str) -> io::Result<Document> {
132 described(table()?.document, id)?
133 .pop()
134 .ok_or_else(|| io::ErrorKind::NotFound.into())
135 }
136
137 fn children(&self, id: &str) -> io::Result<Vec<Document>> {
138 described(table()?.children, id)
139 }
140
141 fn open(&self, id: &str, mode: &str) -> io::Result<File> {
142 let (id, mode) = (CString::new(id)?, CString::new(mode)?);
143 let (mut fd, mut handle) = (-1, 0);
144 let table = table()?;
145 status((table.open)(
146 id.as_ptr(),
147 mode.as_ptr(),
148 &mut fd,
149 &mut handle,
150 ))?;
151 if fd < 0 {
152 status((table.close)(handle))?;
153 return Err(io::Error::other("The provider gave no descriptor"));
154 }
155 // SAFETY: the host hands over a descriptor only this file uses and closes.
156 let file = unsafe { File::from_raw_fd(fd) };
157 self.0
158 .lock()
159 .unwrap_or_else(|error| error.into_inner())
160 .insert(fd, handle);
161 Ok(file)
162 }
163
164 /// The descriptor closes first, so a write's end reaches the provider before the host
165 /// asks for its verdict.
166 fn close(&self, file: File) -> io::Result<()> {
167 let handle = self
168 .0
169 .lock()
170 .unwrap_or_else(|error| error.into_inner())
171 .remove(&file.as_raw_fd());
172 drop(file);
173 match handle {
174 Some(handle) => status((table()?.close)(handle)),
175 None => Ok(()),
176 }
177 }
178
179 fn rename(&self, id: &str, name: &str) -> io::Result<String> {
180 extern "C" fn renamed(context: *mut c_void, id: *const c_char) {
181 // SAFETY: `context` is the id below, alive for the host's call.
182 unsafe { *context.cast::<Option<String>>() = Some(string(id)) };
183 }
184 let (id, name) = (CString::new(id)?, CString::new(name)?);
185 let mut new: Option<String> = None;
186 status((table()?.rename)(
187 id.as_ptr(),
188 name.as_ptr(),
189 renamed,
190 (&raw mut new).cast(),
191 ))?;
192 new.ok_or_else(|| io::Error::other("The provider gave no new id"))
193 }
194}
crates/mobile/src/tests.rs+298
...@@ -3596,3 +3596,301 @@ fn reader_deferred_reload_preserves_rich_composition_after_real_local_write_fail...@@ -3596,3 +3596,301 @@ fn reader_deferred_reload_preserves_rich_composition_after_real_local_write_fail
3596 assert_eq!(page.color, Some(0x0099_8877));3596 assert_eq!(page.color, Some(0x0099_8877));
3597 }3597 }
3598}3598}
3599
3600/// A document provider over real folders, documents named by their paths, failing as the code
3601/// `fail` set for a folder says.
3602mod provider_fake {
3603 use crate::provider::{Found, Table, sb_set_provider};
3604 use crate::string;
3605 use std::{
3606 ffi::{CString, c_char, c_void},
3607 os::fd::IntoRawFd,
3608 path::{Path, PathBuf},
3609 sync::Mutex,
3610 };
3611
3612 static FAILING: Mutex<Vec<(PathBuf, i32)>> = Mutex::new(Vec::new());
3613
3614 /// Has every call on documents in `root` fail with `code`, or none.
3615 pub(super) fn fail(root: &Path, code: Option<i32>) {
3616 let mut failing = FAILING.lock().unwrap();
3617 failing.retain(|(failed, _)| failed != root);
3618 failing.extend(code.map(|code| (root.to_owned(), code)));
3619 }
3620
3621 fn failing(id: *const c_char) -> Result<PathBuf, i32> {
3622 let path = PathBuf::from(string(id));
3623 let failing = FAILING.lock().unwrap();
3624 match failing.iter().find(|(root, _)| path.starts_with(root)) {
3625 Some((_, code)) => Err(*code),
3626 None => Ok(path),
3627 }
3628 }
3629
3630 fn describe(path: &Path, found: Found, context: *mut c_void) -> i32 {
3631 let Ok(metadata) = std::fs::metadata(path) else {
3632 return 3;
3633 };
3634 let id = CString::new(path.to_str().unwrap()).unwrap();
3635 let name = CString::new(path.file_name().unwrap().to_str().unwrap()).unwrap();
3636 let modified = metadata.modified().unwrap();
3637 let modified = modified.duration_since(std::time::UNIX_EPOCH).unwrap();
3638 let (size, modified) = (metadata.len() as i64, modified.as_millis() as i64);
3639 found(
3640 context,
3641 id.as_ptr(),
3642 name.as_ptr(),
3643 metadata.is_dir(),
3644 size,
3645 modified,
3646 true,
3647 );
3648 0
3649 }
3650
3651 extern "C" fn document(id: *const c_char, found: Found, context: *mut c_void) -> i32 {
3652 failing(id).map_or_else(|code| code, |path| describe(&path, found, context))
3653 }
3654
3655 extern "C" fn children(id: *const c_char, found: Found, context: *mut c_void) -> i32 {
3656 let path = match failing(id) {
3657 Ok(path) => path,
3658 Err(code) => return code,
3659 };
3660 let Ok(entries) = std::fs::read_dir(path) else {
3661 return 3;
3662 };
3663 for entry in entries {
3664 describe(&entry.unwrap().path(), found, context);
3665 }
3666 0
3667 }
3668
3669 extern "C" fn open(
3670 id: *const c_char,
3671 mode: *const c_char,
3672 fd: *mut i32,
3673 handle: *mut u64,
3674 ) -> i32 {
3675 let path = match failing(id) {
3676 Ok(path) => path,
3677 Err(code) => return code,
3678 };
3679 let write = string(mode) == "rw";
3680 match std::fs::File::options().read(true).write(write).open(path) {
3681 Ok(file) => {
3682 // SAFETY: the caller passes places for one descriptor and one handle.
3683 unsafe {
3684 fd.write(file.into_raw_fd());
3685 handle.write(1);
3686 }
3687 0
3688 }
3689 Err(_) => 3,
3690 }
3691 }
3692
3693 extern "C" fn close(handle: u64) -> i32 {
3694 assert_eq!(handle, 1);
3695 0
3696 }
3697
3698 extern "C" fn rename(
3699 id: *const c_char,
3700 name: *const c_char,
3701 renamed: extern "C" fn(*mut c_void, *const c_char),
3702 context: *mut c_void,
3703 ) -> i32 {
3704 let path = match failing(id) {
3705 Ok(path) => path,
3706 Err(code) => return code,
3707 };
3708 let new = path.with_file_name(string(name));
3709 if std::fs::rename(&path, &new).is_err() {
3710 return 3;
3711 }
3712 let new = CString::new(new.to_str().unwrap()).unwrap();
3713 renamed(context, new.as_ptr());
3714 0
3715 }
3716
3717 pub(super) fn register() {
3718 // SAFETY: every function keeps the header's contract; `open` hands over its own file.
3719 unsafe {
3720 sb_set_provider(&Table {
3721 document,
3722 children,
3723 open,
3724 close,
3725 rename,
3726 })
3727 };
3728 }
3729}
3730
3731/// The library `sb_library_provider` opens on `root`, or its error.
3732fn provided(root: &Path, folder: bool, cache: &Path) -> std::result::Result<Arc<Library>, String> {
3733 provider_fake::register();
3734 let (root, cache) = (
3735 CString::new(root.to_str().unwrap()).unwrap(),
3736 CString::new(cache.to_str().unwrap()).unwrap(),
3737 );
3738 let mut error = std::ptr::null_mut();
3739 // SAFETY: both strings are NUL-terminated and `error` a place for one.
3740 let library = unsafe {
3741 library::sb_library_provider(root.as_ptr(), folder, true, cache.as_ptr(), &mut error)
3742 };
3743 if library.is_null() {
3744 let message = string(error);
3745 // SAFETY: `error` came from the call above.
3746 unsafe { sb_string_free(error) };
3747 return Err(message);
3748 }
3749 // SAFETY: a library the call above made, freed when the Arc drops (`sb_library_free`).
3750 Ok(unsafe { Arc::from_raw(library) })
3751}
3752
3753/// Types `text` before "Collapsed parent" on Paragraph controls, returning the page.
3754fn typed(section: &Section, text: &str) -> ExGuid {
3755 let mut canvas = canvas(section, "Paragraph controls");
3756 focus(&mut canvas, "Collapsed parent");
3757 canvas.insert(text.into()).unwrap();
3758 section
3759 .shared
3760 .apply(canvas.edit().unwrap().unwrap())
3761 .unwrap();
3762 section.shared.section.replica().written().unwrap();
3763 canvas.space
3764}
3765
3766fn closed(section: Section) {
3767 let shared = Arc::clone(&section.shared);
3768 drop(section);
3769 Arc::into_inner(shared).unwrap().section.close().unwrap();
3770}
3771
3772fn flushed(library: &Library, seconds: f64) -> bool {
3773 // SAFETY: a null error is allowed.
3774 unsafe { library::sb_library_flush(library, seconds, std::ptr::null_mut()) }
3775}
3776
3777#[test]
3778fn a_provider_folder_opens_through_the_c_surface_and_publishes_in_place() {
3779 let (directory, root) = copy("m6/native-features-01/notebook");
3780 let library = provided(&root, true, &directory.path().join("cache")).unwrap();
3781 let tabs = library.tabs().unwrap();
3782 assert!(
3783 tabs.iter()
3784 .any(|tab| tab.path == "Features.one" && tab.readable)
3785 );
3786 let section = open(&library, "Features.one");
3787 let space = typed(&section, "Provided ");
3788 assert!(section.flush(Duration::from_secs(20)));
3789 assert!(stored_text(&root.join("Features.one"), space).contains("Provided "));
3790 closed(section);
3791}
3792
3793#[test]
3794fn a_provider_notebook_cold_opens_offline_as_last_listed_with_its_queued_edits() {
3795 let (directory, root) = copy("m6/native-features-01/notebook");
3796 let cache = directory.path().join("cache");
3797 let library = provided(&root, true, &cache).unwrap();
3798 let listed: Vec<String> = library
3799 .tabs()
3800 .unwrap()
3801 .into_iter()
3802 .map(|tab| tab.path)
3803 .collect();
3804 let section = open(&library, "Features.one");
3805 provider_fake::fail(&root, Some(1));
3806 let space = typed(&section, "Offline ");
3807 assert!(!flushed(&library, 0.5));
3808 closed(section);
3809 drop(library);
3810
3811 let library = provided(&root, true, &cache).unwrap();
3812 let tabs: Vec<String> = library
3813 .tabs()
3814 .unwrap()
3815 .into_iter()
3816 .map(|tab| tab.path)
3817 .collect();
3818 assert_eq!(tabs, listed);
3819 let section = open(&library, "Features.one");
3820 let (page, _) = section.shared.page(space).unwrap();
3821 assert!(canvas::search::page_text(&page).contains("Offline "));
3822 assert_eq!(section.shared.section.pending().unwrap().len(), 1);
3823 assert!(eventually(|| library.sync_status().iter().any(|sync| {
3824 sync.path == "Features.one" && sync.state == SyncState::NotConnected as u8
3825 })));
3826 assert!(!stored_text(&root.join("Features.one"), space).contains("Offline "));
3827 provider_fake::fail(&root, None);
3828 sb_library_sync_now(&library);
3829 assert!(section.flush(Duration::from_secs(20)));
3830 assert!(stored_text(&root.join("Features.one"), space).contains("Offline "));
3831 closed(section);
3832}
3833
3834#[test]
3835fn a_revoked_grant_refuses_to_open_and_keeps_the_queued_edits() {
3836 let (directory, root) = copy("m6/native-features-01/notebook");
3837 let cache = directory.path().join("cache");
3838 let library = provided(&root, true, &cache).unwrap();
3839 let section = open(&library, "Features.one");
3840 provider_fake::fail(&root, Some(2));
3841 let space = typed(&section, "Kept ");
3842 closed(section);
3843 drop(library);
3844 let refused = provided(&root, true, &cache).err().unwrap();
3845 assert!(refused.contains("no longer has access"), "{refused}");
3846 assert!(replicas(&directory) > 0);
3847
3848 provider_fake::fail(&root, None);
3849 let library = provided(&root, true, &cache).unwrap();
3850 let section = open(&library, "Features.one");
3851 assert_eq!(section.shared.section.pending().unwrap().len(), 1);
3852 assert!(section.flush(Duration::from_secs(20)));
3853 assert!(stored_text(&root.join("Features.one"), space).contains("Kept "));
3854 closed(section);
3855}
3856
3857#[test]
3858fn finishing_keeps_offline_copies_and_close_notebook_deletes_them() {
3859 let (directory, root) = copy("m6/native-features-01/notebook");
3860 let cache = directory.path().join("cache");
3861 let library = provided(&root, true, &cache).unwrap();
3862 let readable = library
3863 .tabs()
3864 .unwrap()
3865 .iter()
3866 .filter(|tab| tab.readable)
3867 .count();
3868 assert!(eventually(|| replicas(&directory) == readable));
3869 assert!(flushed(&library, 10.0));
3870 drop(library);
3871 std::thread::sleep(Duration::from_millis(200));
3872 assert_eq!(replicas(&directory), readable);
3873
3874 let library = provided(&root, true, &cache).unwrap();
3875 // SAFETY: a null error is allowed.
3876 assert!(unsafe { library::sb_library_close(&library, 10.0, std::ptr::null_mut()) });
3877 drop(library);
3878 assert!(eventually(|| replicas(&directory) == 0));
3879}
3880
3881#[test]
3882fn a_lone_provider_document_opens_and_publishes() {
3883 let (directory, file) = copy("media-edit/candidate/Features.one");
3884 let library = provided(&file, false, &directory.path().join("cache")).unwrap();
3885 let tabs = library.tabs().unwrap();
3886 assert_eq!(
3887 (tabs[0].name.as_str(), tabs[0].path.as_str()),
3888 ("Features", "")
3889 );
3890 let section = open(&library, "");
3891 let space = typed(&section, "Alone ");
3892 // As finishing flushes: the library, its open section and no background.
3893 assert!(flushed(&library, 20.0));
3894 assert!(stored_text(&file, space).contains("Alone "));
3895 closed(section);
3896}