authorclover caruso <git@paperclover.net> 2026-10-10 19:02:40-07:00
committerclover caruso <git@paperclover.net> 2026-10-10 22:56:05-07:00
log9a109461103375723db50473c2562c7195eea36c
tree03bd6f82500086f311815dc1299e8e520693d555
parent34a0250c70ab90c59387f05aa7840d58535392a6
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

fix: keep cached mobile notebook contents available offline

Retain notebook objects after failed cloud-folder listings, reuse cached section identities when iCloud replaces files with placeholders, and resume cached sections while the source file is absent. Preserve canonical parent and symlink boundary checks. Cover placeholder eviction, queued offline edits, restoration, and links outside the notebook. Fixes #110 Assisted-by: gpt-6.1-sol

6 files changed, 129 insertions(+), 14 deletions(-)

apps/ios/Snowbound/Notebooks.swift+8-6
......@@ -523,9 +523,10 @@ enum Notebooks {
523523 list = []
524524 return []
525525 }
526 guard let names = notebooks(in: folder) else { return [] }
526527 var added: [Notebook] = []
527528 list = ordered(
528 notebooks(in: folder).map { [list] name in
529 names.map { [list] name in
529530 if let kept = list.first(where: { $0.id == prefix + name }) { return kept }
530531 let notebook = Notebook(
531532 id: prefix + name, name: (name as NSString).deletingPathExtension, source: source(name))
......@@ -564,15 +565,16 @@ enum Notebooks {
564565 /// Whether Snowbound's folder in iCloud Drive holds other notebooks than the list shows, as
565566 /// when another device added or removed one.
566567 static var inCloudChanged: Bool {
567 guard let folder = ICloud.documents, scripted == nil else { return false }
568 return notebooks(in: folder).map { "iCloud/" + $0 } != inCloud.map(\.id)
568 guard let folder = ICloud.documents, scripted == nil, let names = notebooks(in: folder) else { return false }
569 return names.map { "iCloud/" + $0 } != inCloud.map(\.id)
569570 }
570571
571572 /// The notebook folders and section files in `folder`, by name, as Files orders them.
572 private static func notebooks(in folder: URL) -> [String] {
573 let listed = try? FileManager.default.contentsOfDirectory(
573 private static func notebooks(in folder: URL) -> [String]? {
574 guard let listed = try? FileManager.default.contentsOfDirectory(
574575 at: folder, includingPropertiesForKeys: [.isDirectoryKey], options: .skipsHiddenFiles)
575 return (listed ?? []).filter { url in
576 else { return nil }
577 return listed.filter { url in
576578 (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
577579 || url.pathExtension.lowercased() == "one"
578580 }
crates/mobile/src/tests.rs+59
......@@ -317,6 +317,65 @@ fn typing_is_stored_in_the_cache_and_published_to_the_file() {
317317 assert!(canvas::search::page_text(&page).contains("Saved Collapsed parent"));
318318}
319319
320#[test]
321fn cached_cloud_sections_reopen_with_offline_edits_after_eviction() {
322 let directory = tempfile::tempdir().unwrap();
323 let root = directory.path().join("notebook");
324 let cache = directory.path().join("cache");
325 let creation = onestore::PageCreation::new(None, Some("Kept offline"), "Fixture").unwrap();
326 let notebook =
327 notebook::session::Notebook::create(&root, &cache, 0x00f0_a0c0, &creation).unwrap();
328 let path = notebook.catalog().sections[0].path.clone();
329 drop(notebook);
330 let library = Arc::new(Library::open(&root, &cache, false).unwrap());
331 sb_library_set_offline(&library, true);
332 let section = open(&library, &path);
333 let space = space(&section, "Kept offline");
334 section
335 .shared
336 .apply(Edit {
337 at: library::filetime(),
338 ops: vec![Op::Page {
339 space,
340 op: PageOp::Color(Some(0x00ef_cdab)),
341 }],
342 })
343 .unwrap();
344 let shared = Arc::clone(&section.shared);
345 drop((section, library));
346 Arc::into_inner(shared).unwrap().section.close().unwrap();
347 let file = root.join(&path);
348 let held = directory.path().join("held.one");
349 std::fs::rename(&file, &held).unwrap();
350 let placeholder = root.join(format!(".{path}.icloud"));
351 std::fs::write(&placeholder, b"placeholder").unwrap();
352
353 let library = Arc::new(Library::open(&root, &cache, false).unwrap());
354 sb_library_set_offline(&library, true);
355 for _ in 0..2 {
356 let tabs = library.tabs().unwrap();
357 assert_eq!(tabs[0].path, path);
358 assert!(tabs[0].readable && !tabs[0].downloading);
359 }
360 let section = open(&library, &path);
361 assert_eq!(section.rows().unwrap()[0].title, "Kept offline");
362 assert_eq!(
363 section.shared.page(space).unwrap().0.color,
364 Some(0x00ef_cdab)
365 );
366 assert_eq!(section.shared.section.pending().unwrap().len(), 1);
367 assert!(!file.exists());
368
369 std::fs::rename(held, &file).unwrap();
370 std::fs::remove_file(placeholder).unwrap();
371 assert!(library.tabs().unwrap()[0].readable);
372 sb_library_set_offline(&library, false);
373 assert!(section.flush(Duration::from_secs(20)));
374 let arena = onestore::Arena::default();
375 let stored = onestore::Section::open(&arena, onestore::read_file(file).unwrap()).unwrap();
376 assert_eq!(stored.page(space).unwrap().color, Some(0x00ef_cdab));
377}
378
320379#[test]
321380fn a_new_page_and_subpage_list_after_their_parent_and_delete_to_the_recycle_bin() {
322381 let (directory, root) = copy("m6/native-features-01/notebook");
crates/notebook/src/discover.rs+2-4
......@@ -408,11 +408,9 @@ fn scan(
408408 };
409409 let reused = cached
410410 .get(&child)
411 .filter(|known| known.listed == entry.listed)
411 .filter(|known| entry.kind == EntryKind::Evicted || known.listed == entry.listed)
412412 .and_then(|known| Some((known.file_id()?, known)));
413 // An evicted file listed as it was last read lists as then. Without an evicted TOC the
414 // folder lists in path order; nothing writes a second one, as its placeholder, or the
415 // dataless file itself, blocks creating it.
413 // A placeholder's metadata describes the placeholder, not the cached section.
416414 if entry.kind == EntryKind::Evicted && reused.is_none() {
417415 if expected == FileType::Section {
418416 result.unavailable.push(Unavailable {
crates/notebook/src/session.rs+13-2
......@@ -1651,8 +1651,19 @@ impl Notebook {
16511651 )
16521652 .into());
16531653 };
1654 // A section replaced by a link out of the notebook is not the catalog's section.
1655 let file = fs::canonicalize(root.join(path))?;
1654 let file = root.join(path);
1655 // Evicted files resume their replicas; existing links still resolve within the root.
1656 let file = match fs::symlink_metadata(&file) {
1657 Err(error) if error.kind() == io::ErrorKind::NotFound => fs::canonicalize(
1658 file.parent()
1659 .ok_or(io::Error::from(io::ErrorKind::InvalidInput))?,
1660 )?
1661 .join(
1662 file.file_name()
1663 .ok_or(io::Error::from(io::ErrorKind::InvalidInput))?,
1664 ),
1665 _ => fs::canonicalize(&file)?,
1666 };
16561667 if !file.starts_with(root) {
16571668 return Err(io::Error::from(io::ErrorKind::PermissionDenied).into());
16581669 }
crates/notebook/src/session/tests.rs+19
......@@ -1,5 +1,24 @@
11use super::*;
22
3#[cfg(unix)]
4#[test]
5fn cached_sections_refuse_links_outside_the_notebook_and_dangling_links() {
6 let temporary = tempfile::tempdir().unwrap();
7 let folder = temporary.path().join("notebook");
8 let cache = temporary.path().join("cache");
9 let creation = PageCreation::new(None, Some("Mine"), "Fixture").unwrap();
10 let notebook = Notebook::create(&folder, &cache, 0x00f0_a0c0, &creation).unwrap();
11 let path = &notebook.catalog().sections[0].path;
12 notebook.section(path, || {}).unwrap().close().unwrap();
13 let file = folder.join(path);
14 let outside = temporary.path().join("outside.one");
15 fs::rename(&file, &outside).unwrap();
16 std::os::unix::fs::symlink(&outside, &file).unwrap();
17 assert!(notebook.section(path, || {}).is_err());
18 fs::remove_file(outside).unwrap();
19 assert!(notebook.section(path, || {}).is_err());
20}
21
322/// A mounted notebook where another writer commits `revision` to the section file just
423/// before Snowbound's rewritten file takes its place.
524struct Racing {
crates/notebook/tests/discovery.rs+28-2
......@@ -197,6 +197,33 @@ fn evicted_files_list_under_their_names_until_downloaded() {
197197 assert!(!root.join("Open Notebook.onetoc2").exists());
198198}
199199
200#[test]
201fn cached_sections_and_tocs_survive_eviction_to_different_placeholder_metadata() {
202 let root = tempfile::tempdir().unwrap();
203 fixture(root.path());
204 let mut cache = Cache::default();
205 let mut source = Local::open(root.path()).unwrap();
206 let before = cache.discover(&mut source, limits()).unwrap();
207 for name in ["one.one", "Open Notebook.onetoc2"] {
208 fs::remove_file(root.path().join(name)).unwrap();
209 fs::write(root.path().join(format!(".{name}.icloud")), b"placeholder").unwrap();
210 }
211 for _ in 0..2 {
212 let evicted = cache.discover(&mut source, limits()).unwrap();
213 assert_eq!(evicted.sections[0].file_id, before.sections[0].file_id);
214 assert_eq!(evicted.order, before.order);
215 assert!(evicted.toc.is_some() && evicted.unavailable.is_empty());
216 }
217 fs::write(
218 root.path().join("one.one"),
219 onestore::create_section("one.one", "Downloaded change", "Fixture").unwrap(),
220 )
221 .unwrap();
222 touch(&root.path().join("one.one"), 1_000_000_000);
223 let downloaded = cache.discover(&mut source, limits()).unwrap();
224 assert_ne!(downloaded.sections[0].file_id, before.sections[0].file_id);
225}
226
200227/// A source whose files list as macOS lists those iCloud Drive evicted: dataless, under their
201228/// names and listings, and never read.
202229struct Dataless(Local);
......@@ -215,8 +242,7 @@ impl Source for Dataless {
215242 }
216243}
217244
218/// A dataless file lists as its last read found it, where it lists unchanged since, and as not
219/// downloaded otherwise.
245/// A dataless file lists as its last read found it, and waits for download without a cache.
220246#[test]
221247fn dataless_files_list_as_last_read_or_wait_for_their_download() {
222248 let root = tempfile::tempdir().unwrap();