| author | |
| committer | |
| log | 201763c9e037596207365ab10fd3ed5a6b9a6ff4 |
| tree | 487f88ca060c1000622fb015bb5f693e5c1909a0 |
| parent | ffaff48d67462562d069edde11886b41bb411e8c |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover) |
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.56 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 | } |
| 356 | 356 | ||
| 357 | // Frees the section, then publishes the notebook's edits and lets go of it: false, the edits | 357 | // 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). | ||
| 359 | JNIEXPORT jboolean JNICALL JNI(close)(JNIEnv *env, jclass cls, jlong library, jlong section, jdouble seconds) { | 361 | JNIEXPORT 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); |
| 27 | char *sb_pens(uint32_t section); | 27 | char *sb_pens(uint32_t section); |
| 28 | bool sb_tag_icon(uint16_t shape, bool checked, uint32_t pixels, uint8_t *rgba); | 28 | bool sb_tag_icon(uint16_t shape, bool checked, uint32_t pixels, uint8_t *rgba); |
| 29 | 29 | ||
| 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. | ||
| 37 | typedef void (*sb_document_found)(void *context, const char *id, const char *name, bool directory, int64_t size, | ||
| 38 | int64_t modified, bool writable); | ||
| 39 | typedef 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; | ||
| 50 | void sb_set_provider(const sb_provider *provider); | ||
| 51 | |||
| 30 | const Library *sb_library_open(const char *path, const char *cache, bool local, char **error); | 52 | const Library *sb_library_open(const char *path, const char *cache, bool local, char **error); |
| 53 | const Library *sb_library_provider(const char *root, bool folder, bool local, const char *cache, char **error); | ||
| 31 | const Library *sb_library_server(const char *address, const char *share, const char *user, const char *password, | 54 | const 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); |
| 57 | void sb_library_set_offline(const Library *library, bool offline); | 80 | void sb_library_set_offline(const Library *library, bool offline); |
| 58 | void sb_library_touched(const Library *library, const char *path); | 81 | void sb_library_touched(const Library *library, const char *path); |
| 59 | void sb_library_sync_now(const Library *library); | 82 | void sb_library_sync_now(const Library *library); |
| 83 | // Publishes, keeping offline copies; sb_library_close is Close Notebook and deletes them. | ||
| 84 | bool sb_library_flush(const Library *library, double seconds, char **error); | ||
| 60 | bool sb_library_close(const Library *library, double seconds, char **error); | 85 | bool sb_library_close(const Library *library, double seconds, char **error); |
| 61 | void sb_library_free(const Library *library); | 86 | void sb_library_free(const Library *library); |
| 62 | 87 |
crates/mobile/src/lib.rs+1| ... | @@ -9,6 +9,7 @@ | ... | @@ -9,6 +9,7 @@ |
| 9 | 9 | ||
| 10 | mod crash; | 10 | mod crash; |
| 11 | mod library; | 11 | mod library; |
| 12 | mod provider; | ||
| 12 | mod recording; | 13 | mod recording; |
| 13 | #[cfg(target_os = "ios")] | 14 | #[cfg(target_os = "ios")] |
| 14 | mod spell; | 15 | mod 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 | } |
| 227 | 233 | ||
| 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 | } |
| 504 | 510 | ||
| 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 { |
| 619 | 662 | ||
| 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 = notebook | 813 | 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 | } |
| 783 | 849 | ||
| 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 holding | 1346 | /// 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 | } |
| 1344 | 1419 | ||
| ... | @@ -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 = self | 1623 | 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 | } |
| 2129 | 2206 | ||
| 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)] | ||
| 2214 | pub 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)] | ||
| 2234 | pub 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 | /// # Safety | 2247 | /// # 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)] |
| 2133 | pub unsafe extern "C" fn sb_library_free(library: *const Library) { | 2251 | pub 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 | |||
| 5 | use crate::string; | ||
| 6 | use notebook::provider::{Document, Provider}; | ||
| 7 | use 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. | ||
| 19 | pub 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)] | ||
| 33 | pub 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 | |||
| 58 | static 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)] | ||
| 66 | pub unsafe extern "C" fn sb_set_provider(table: &Table) { | ||
| 67 | let _ = TABLE.set(*table); | ||
| 68 | } | ||
| 69 | |||
| 70 | fn 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 | |||
| 79 | fn 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 | |||
| 96 | fn 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)] | ||
| 128 | pub(crate) struct Host(Mutex<HashMap<i32, u64>>); | ||
| 129 | |||
| 130 | impl 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. | ||
| 3602 | mod 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. | ||
| 3732 | fn 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. | ||
| 3754 | fn 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 | |||
| 3766 | fn closed(section: Section) { | ||
| 3767 | let shared = Arc::clone(&section.shared); | ||
| 3768 | drop(section); | ||
| 3769 | Arc::into_inner(shared).unwrap().section.close().unwrap(); | ||
| 3770 | } | ||
| 3771 | |||
| 3772 | fn 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] | ||
| 3778 | fn 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] | ||
| 3794 | fn 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] | ||
| 3835 | fn 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] | ||
| 3858 | fn 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] | ||
| 3882 | fn 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 | } |