Skip to content

Commit f304ef8

Browse files
authored
fix(backup): survive iCloud's fresh-install metadata race; make restore resumable (build 11) (#21)
On a real device a fresh install's ubiquity container is cloud-only until metadata syncs; the restore used plain filesystem walks, missed the un-materialized note bodies, and installing the catalog blocked any retry - notes came back with titles but no content (found on-device in 1.3 build 10; the dev-override simulator E2E structurally cannot catch this). - ICloudBackupModule: listCloudFiles via NSMetadataQuery (sees cloud- only items and nudges the sync; main-queue lifecycle with timeout and double-completion guard; /private symlink standardization; cloud-only directories classified by content type). ensureDownloaded now retries startDownloadingUbiquitousItem until the deadline instead of giving up when metadata has not arrived. - backupEngine: manifest and backup catalog reads are download-forced too (the same race can hit them). Restore now MERGES the backup catalog - backup metadata adopted for missing notes and recovery stubs, local notes and tombstones untouched - and is resumable: resumeRestoreIfIncomplete heals notes-without-bodies and stub titles on every launch, gated to files iCloud still lists so a stale manifest entry cannot cause a retry-forever loop. - useBackup runs the silent resume before the startup sync, so devices already damaged by build 10 self-heal on first launch of this build. Verified: 48 node tests including the device repro (partial restore resumes to completion) and the mirror-image race (stub titles heal); simulator E2E of the exact broken device state - catalog with titles, zero bodies, one a stub - fully healed on launch with no prompts. Claude-Session: https://claude.ai/code/session_01Vy3wDR2rgpvNKrbBpCbrEY
1 parent e08b435 commit f304ef8

10 files changed

Lines changed: 407 additions & 62 deletions

File tree

android/app/build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ android {
9292
applicationId 'com.builderpro.opennotes'
9393
minSdkVersion rootProject.ext.minSdkVersion
9494
targetSdkVersion rootProject.ext.targetSdkVersion
95-
versionCode 10
95+
versionCode 11
9696
versionName "1.3"
9797

9898
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""

app.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"ios": {
1717
"bundleIdentifier": "com.builderpro.opennotes",
1818
"icon": "./assets/icon.png",
19-
"buildNumber": "10",
19+
"buildNumber": "11",
2020
"supportsTablet": true,
2121
"usesIcloudStorage": true,
2222
"entitlements": {
@@ -65,7 +65,7 @@
6565
},
6666
"android": {
6767
"package": "com.builderpro.opennotes",
68-
"versionCode": 10,
68+
"versionCode": 11,
6969
"adaptiveIcon": {
7070
"foregroundImage": "./assets/adaptive-icon.png",
7171
"backgroundColor": "#F7F7F4"

ios/OpenNotes.xcodeproj/project.pbxproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@
364364
CODE_SIGN_ENTITLEMENTS = OpenNotes/OpenNotes.entitlements;
365365
CODE_SIGN_IDENTITY = "Apple Development";
366366
CODE_SIGN_STYLE = Automatic;
367-
CURRENT_PROJECT_VERSION = 10;
367+
CURRENT_PROJECT_VERSION = 11;
368368
DEVELOPMENT_TEAM = U2CPXQV7AJ;
369369
ENABLE_BITCODE = NO;
370370
GCC_PREPROCESSOR_DEFINITIONS = (
@@ -403,7 +403,7 @@
403403
CODE_SIGN_ENTITLEMENTS = OpenNotes/OpenNotes.entitlements;
404404
CODE_SIGN_IDENTITY = "Apple Development";
405405
CODE_SIGN_STYLE = Automatic;
406-
CURRENT_PROJECT_VERSION = 10;
406+
CURRENT_PROJECT_VERSION = 11;
407407
DEVELOPMENT_TEAM = U2CPXQV7AJ;
408408
INFOPLIST_FILE = OpenNotes/Info.plist;
409409
IPHONEOS_DEPLOYMENT_TARGET = 15.1;

ios/OpenNotes/ICloudBackupModule.m

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,9 @@ @interface RCT_EXTERN_MODULE(ICloudBackupModule, NSObject)
3232
resolver:(RCTPromiseResolveBlock)resolver
3333
rejecter:(RCTPromiseRejectBlock)rejecter)
3434

35+
RCT_EXTERN_METHOD(listCloudFiles:(NSString *)dir
36+
timeoutMs:(nonnull NSNumber *)timeoutMs
37+
resolver:(RCTPromiseResolveBlock)resolver
38+
rejecter:(RCTPromiseRejectBlock)rejecter)
39+
3540
@end

ios/OpenNotes/ICloudBackupModule.swift

Lines changed: 77 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -159,9 +159,10 @@ class ICloudBackupModule: NSObject {
159159
return path
160160
}
161161

162-
/// Ensures a file in the ubiquity container is downloaded locally (iCloud
163-
/// may have evicted it). Resolves true when the file is readable, false when
164-
/// the download did not complete within the timeout.
162+
/// Ensures a file in the ubiquity container is downloaded locally. Handles
163+
/// all three states of a fresh install: file present, iCloud placeholder
164+
/// present, or cloud metadata not yet synced (startDownloading is retried
165+
/// until the metadata arrives). Resolves true when the file is readable.
165166
@objc
166167
func ensureDownloaded(_ path: String,
167168
timeoutMs: NSNumber,
@@ -170,36 +171,86 @@ class ICloudBackupModule: NSObject {
170171
DispatchQueue.global(qos: .utility).async {
171172
let url = URL(fileURLWithPath: Self.plainPath(path))
172173
let fileManager = FileManager.default
173-
174-
if fileManager.fileExists(atPath: url.path) {
175-
resolver(true)
176-
return
177-
}
178-
179-
// A not-yet-downloaded ubiquitous file appears as ".<name>.icloud".
180-
let placeholderName = ".\(url.lastPathComponent).icloud"
181-
let placeholderUrl = url.deletingLastPathComponent().appendingPathComponent(placeholderName)
182-
guard fileManager.fileExists(atPath: placeholderUrl.path) else {
183-
resolver(false)
184-
return
185-
}
186-
187-
do {
188-
try fileManager.startDownloadingUbiquitousItem(at: url)
189-
} catch {
190-
rejecter("E_DOWNLOAD_START", "Could not start iCloud download for \(url.lastPathComponent)", error)
191-
return
192-
}
193-
194174
let deadline = Date().addingTimeInterval(timeoutMs.doubleValue / 1000.0)
175+
195176
while Date() < deadline {
196177
if fileManager.fileExists(atPath: url.path) {
197178
resolver(true)
198179
return
199180
}
200-
Thread.sleep(forTimeInterval: 0.2)
181+
// Registers download interest. Throws while the item's cloud metadata
182+
// has not synced down yet - keep retrying until the deadline.
183+
try? fileManager.startDownloadingUbiquitousItem(at: url)
184+
Thread.sleep(forTimeInterval: 0.4)
201185
}
202-
resolver(false)
186+
resolver(fileManager.fileExists(atPath: url.path))
187+
}
188+
}
189+
190+
/// Lists every item iCloud knows about under the container's Documents dir
191+
/// via NSMetadataQuery - the canonical discovery API. Unlike a directory
192+
/// walk it sees items whose contents have not been downloaded yet, and
193+
/// running it nudges the metadata sync on a fresh install. Returns
194+
/// [{ rel, size, downloaded }] with `rel` relative to the given directory.
195+
@objc
196+
func listCloudFiles(_ dir: String,
197+
timeoutMs: NSNumber,
198+
resolver: @escaping RCTPromiseResolveBlock,
199+
rejecter: @escaping RCTPromiseRejectBlock) {
200+
DispatchQueue.main.async {
201+
// Standardize through the /private symlink so prefix comparison cannot
202+
// silently drop every result (/var vs /private/var).
203+
let resolvedBase = URL(fileURLWithPath: Self.plainPath(dir))
204+
.resolvingSymlinksInPath().path
205+
let basePath = resolvedBase.hasSuffix("/") ? resolvedBase : resolvedBase + "/"
206+
let query = NSMetadataQuery()
207+
query.searchScopes = [
208+
NSMetadataQueryUbiquitousDocumentsScope,
209+
NSMetadataQueryUbiquitousDataScope,
210+
]
211+
query.predicate = NSPredicate(format: "%K LIKE '*'", NSMetadataItemFSNameKey)
212+
213+
var finished = false
214+
var observer: NSObjectProtocol?
215+
func complete() {
216+
guard !finished else { return }
217+
finished = true
218+
query.disableUpdates()
219+
query.stop()
220+
if let obs = observer { NotificationCenter.default.removeObserver(obs) }
221+
var out: [[String: Any]] = []
222+
for case let item as NSMetadataItem in query.results {
223+
guard let rawPath = item.value(forAttribute: NSMetadataItemPathKey) as? String
224+
else { continue }
225+
let itemPath = URL(fileURLWithPath: rawPath).resolvingSymlinksInPath().path
226+
guard itemPath.hasPrefix(basePath) else { continue }
227+
// Cloud-only directories have no on-disk presence; classify by the
228+
// metadata content type, falling back to the filesystem.
229+
if let contentType = item.value(
230+
forAttribute: NSMetadataItemContentTypeKey) as? String,
231+
contentType == "public.folder" { continue }
232+
var isDir: ObjCBool = false
233+
if FileManager.default.fileExists(atPath: itemPath, isDirectory: &isDir),
234+
isDir.boolValue { continue }
235+
let rel = String(itemPath.dropFirst(basePath.count))
236+
let size = (item.value(forAttribute: NSMetadataItemFSSizeKey) as? NSNumber)?.intValue ?? 0
237+
let status = item.value(
238+
forAttribute: NSMetadataUbiquitousItemDownloadingStatusKey) as? String
239+
let downloaded = status == NSMetadataUbiquitousItemDownloadingStatusCurrent
240+
|| status == NSMetadataUbiquitousItemDownloadingStatusDownloaded
241+
// size/downloaded are surfaced for diagnostics; JS keys off rel.
242+
out.append(["rel": rel, "size": size, "downloaded": downloaded])
243+
}
244+
resolver(out)
245+
}
246+
247+
observer = NotificationCenter.default.addObserver(
248+
forName: .NSMetadataQueryDidFinishGathering, object: query, queue: .main
249+
) { _ in complete() }
250+
DispatchQueue.main.asyncAfter(
251+
deadline: .now() + timeoutMs.doubleValue / 1000.0
252+
) { complete() }
253+
query.start()
203254
}
204255
}
205256
}

ios/OpenNotes/Info.plist

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
</dict>
5555
</array>
5656
<key>CFBundleVersion</key>
57-
<string>10</string>
57+
<string>11</string>
5858
<key>ITSAppUsesNonExemptEncryption</key>
5959
<false/>
6060
<key>LSMinimumSystemVersion</key>

scripts/backupEngine.test.mjs

Lines changed: 116 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@ import {
66
BACKUP_SUBDIR,
77
checkRestoreAvailable,
88
isSafeRelPath,
9+
mergeBackupCatalog,
910
noteIdForRel,
1011
restoreFromBackup,
12+
resumeRestoreIfIncomplete,
1113
syncBackup,
1214
} from '../src/services/backupEngine.ts';
15+
import { RECOVERED_NOTE_TITLE } from '../src/services/catalogStore.ts';
1316

1417
const CONTAINER = '/icloud/Documents';
1518
const BACKUP_DIR = `${CONTAINER}/${BACKUP_SUBDIR}`;
@@ -424,7 +427,9 @@ test('restore copies everything, installs the catalog, and round-trips a full ba
424427
const result = await restoreFromBackup(fresh);
425428
assert.deepEqual(result, { status: 'ok', restored: 3 });
426429
assert.equal(fresh.localFiles.get('notebook-bodies/note-a.body').contents, 'body-a');
427-
assert.equal(fresh.localCatalog, catalogRaw(['note-a']));
430+
const restoredCatalog = JSON.parse(fresh.localCatalog);
431+
assert.deepEqual(restoredCatalog.notes.map((n) => n.id), ['note-a']);
432+
assert.equal(restoredCatalog.notes[0].title, 'Title note-a');
428433
});
429434

430435
test('EDGE: restore never overwrites files that already exist locally', async () => {
@@ -440,7 +445,7 @@ test('EDGE: restore never overwrites files that already exist locally', async ()
440445
assert.equal(env.localFiles.get('notebook-bodies/note-a.body').contents, 'local-version');
441446
});
442447

443-
test('EDGE: restore never overwrites a non-empty local catalog', async () => {
448+
test('EDGE: restore merges into a non-empty local catalog without clobbering local notes', async () => {
444449
const env = makeEnv({
445450
localCatalog: catalogRaw(['note-local']),
446451
backup: {
@@ -449,7 +454,10 @@ test('EDGE: restore never overwrites a non-empty local catalog', async () => {
449454
},
450455
});
451456
await restoreFromBackup(env);
452-
assert.equal(env.localCatalog, catalogRaw(['note-local']));
457+
const merged = JSON.parse(env.localCatalog);
458+
const ids = merged.notes.map((n) => n.id).sort();
459+
assert.deepEqual(ids, ['note-backup', 'note-local']);
460+
assert.equal(merged.notes.find((n) => n.id === 'note-local').title, 'Title note-local');
453461
});
454462

455463
test('EDGE: evicted (undownloadable) files are counted as failures, rest still restores', async () => {
@@ -479,6 +487,111 @@ test('EDGE: restore with corrupt backup catalog still restores body files', asyn
479487
assert.equal(env.localCatalog, null);
480488
});
481489

490+
test('mergeBackupCatalog: stubs healed, user notes kept, backup-only added, tombstones respected', () => {
491+
const local = JSON.parse(catalogRaw(['note-user', 'note-stub']));
492+
local.notes[1].title = RECOVERED_NOTE_TITLE;
493+
local.deletedNoteIds = { 'note-deleted': '2026-08-30T00:00:00.000Z' };
494+
const backup = JSON.parse(catalogRaw(['note-stub', 'note-cloud-only', 'note-deleted']));
495+
const merged = mergeBackupCatalog(JSON.stringify(local), JSON.stringify(backup));
496+
const titles = Object.fromEntries(merged.notes.map((n) => [n.id, n.title]));
497+
assert.equal(titles['note-user'], 'Title note-user');
498+
assert.equal(titles['note-stub'], 'Title note-stub');
499+
assert.equal(titles['note-cloud-only'], 'Title note-cloud-only');
500+
assert.equal('note-deleted' in titles, false);
501+
assert.deepEqual(Object.keys(merged.deletedNoteIds), ['note-deleted']);
502+
});
503+
504+
test('DEVICE REPRO: partial restore (cloud metadata race) resumes to completion on later launches', async () => {
505+
// Fresh install on a real device: catalog synced fast, bodies were still
506+
// cloud-only. The restore installed titles but no content. The resume pass
507+
// must detect notes-without-bodies and pull them once downloadable.
508+
const backup = {
509+
[`${BACKUP_DIR}/${BACKUP_CATALOG_NAME}`]: catalogRaw(['note-a', 'note-b']),
510+
[`${BACKUP_DIR}/${BACKUP_MANIFEST_NAME}`]: JSON.stringify({
511+
version: 1,
512+
files: {
513+
'notebook-bodies/note-a.body': { size: 1, mtimeMs: 1 },
514+
'notebook-bodies/note-b.body': { size: 1, mtimeMs: 1 },
515+
},
516+
lastBackupAt: 5,
517+
noteCount: 2,
518+
}),
519+
[`${BACKUP_DIR}/notebook-bodies/note-a.body`]: 'body-a',
520+
[`${BACKUP_DIR}/notebook-bodies/note-b.body`]: 'body-b',
521+
};
522+
const undownloadable = new Set([
523+
'notebook-bodies/note-a.body',
524+
'notebook-bodies/note-b.body',
525+
]);
526+
const env = makeEnv({ backup, undownloadableRels: undownloadable });
527+
528+
const first = await restoreFromBackup(env);
529+
assert.equal(first.status, 'partial');
530+
assert.equal(first.restored, 0);
531+
// Titles arrived via catalog merge, content did not - the reported state.
532+
assert.equal(JSON.parse(env.localCatalog).notes.length, 2);
533+
assert.equal(env.localFiles.size, 0);
534+
535+
// Next launch, iCloud metadata has synced: resume completes the restore.
536+
undownloadable.clear();
537+
const resumed = await resumeRestoreIfIncomplete(env);
538+
assert.equal(resumed.status, 'ok');
539+
assert.equal(resumed.restored, 2);
540+
assert.equal(env.localFiles.get('notebook-bodies/note-a.body').contents, 'body-a');
541+
542+
// Fully healed: nothing further to resume.
543+
assert.equal(await resumeRestoreIfIncomplete(env), null);
544+
});
545+
546+
test('EDGE: resume does nothing for an empty library (prompt path owns that) or complete one', async () => {
547+
const empty = makeEnv({
548+
backup: { [`${BACKUP_DIR}/notebook-bodies/note-a.body`]: 'a' },
549+
});
550+
assert.equal(await resumeRestoreIfIncomplete(empty), null);
551+
552+
const complete = makeEnv({
553+
local: { 'notebook-bodies/note-a.body': localFile('a') },
554+
localCatalog: catalogRaw(['note-a']),
555+
backup: { [`${BACKUP_DIR}/notebook-bodies/note-a.body`]: 'a' },
556+
});
557+
assert.equal(await resumeRestoreIfIncomplete(complete), null);
558+
});
559+
560+
test('EDGE: a manifest-only rel (file gone from iCloud) never causes an endless resume loop', async () => {
561+
const env = makeEnv({
562+
local: { 'notebook-bodies/note-a.body': localFile('a') },
563+
localCatalog: catalogRaw(['note-a', 'note-gone']),
564+
backup: {
565+
[`${BACKUP_DIR}/${BACKUP_MANIFEST_NAME}`]: JSON.stringify({
566+
version: 1,
567+
files: { 'notebook-bodies/note-gone.body': { size: 1, mtimeMs: 1 } },
568+
lastBackupAt: 1,
569+
noteCount: 2,
570+
}),
571+
},
572+
});
573+
// The file exists only in the stale manifest, not in any listing: gone.
574+
assert.equal(await resumeRestoreIfIncomplete(env), null);
575+
});
576+
577+
test('EDGE: stub titles heal from the backup catalog even when all bodies are present', async () => {
578+
// The device race running the other way: bodies synced first, catalog was
579+
// cloud-only during the first restore, reconciliation created stubs.
580+
const local = JSON.parse(catalogRaw(['note-a']));
581+
local.notes[0].title = RECOVERED_NOTE_TITLE;
582+
const env = makeEnv({
583+
local: { 'notebook-bodies/note-a.body': localFile('a') },
584+
localCatalog: JSON.stringify(local),
585+
backup: {
586+
[`${BACKUP_DIR}/${BACKUP_CATALOG_NAME}`]: catalogRaw(['note-a']),
587+
[`${BACKUP_DIR}/notebook-bodies/note-a.body`]: 'a',
588+
},
589+
});
590+
const result = await resumeRestoreIfIncomplete(env);
591+
assert.equal(result.status, 'ok');
592+
assert.equal(JSON.parse(env.localCatalog).notes[0].title, 'Title note-a');
593+
});
594+
482595
test('EDGE: an env that throws unexpectedly yields partial, not a crash', async () => {
483596
const env = makeEnv({ local: { 'notebook-bodies/a.body': localFile('a') } });
484597
env.listLocalDataFiles = async () => {

src/hooks/useBackup.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
isBackupAvailable,
77
isBackupEnabled,
88
performBackupRestore,
9+
resumeBackupRestoreIfIncomplete,
910
scheduleBackupSync,
1011
setBackupEnabled,
1112
} from '../services/backupService';
@@ -65,6 +66,14 @@ export function useBackup(
6566
if (restorePromptShownRef.current) return;
6667
const availability = await checkBackupRestoreAvailable();
6768
if (!availability || restorePromptShownRef.current) {
69+
// Heal any interrupted restore first: notes whose titles arrived but
70+
// whose content files are still only in iCloud (a fresh install can
71+
// race the metadata sync). Copies only what is missing; silent.
72+
const resumed = await resumeBackupRestoreIfIncomplete();
73+
if (resumed && resumed.status !== 'unavailable' && resumed.restored > 0) {
74+
await catalogStore.invalidate();
75+
await refreshLibrary();
76+
}
6877
// No restore pending: safe to start the catch-up push. It retries a
6978
// backup that failed last session and mirrors pre-existing data after
7079
// an app update; a no-op copy-wise when the mirror is current. It is

0 commit comments

Comments
 (0)