From 8eb2cf29b0eccb421ea0a733905043f026d84448 Mon Sep 17 00:00:00 2001 From: markm39 Date: Sun, 30 Aug 2026 14:40:23 -0500 Subject: [PATCH] fix(backup): survive iCloud's fresh-install metadata race; make restore resumable (build 11) 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 --- android/app/build.gradle | 2 +- app.json | 4 +- ios/OpenNotes.xcodeproj/project.pbxproj | 4 +- ios/OpenNotes/ICloudBackupModule.m | 5 + ios/OpenNotes/ICloudBackupModule.swift | 103 ++++++++++---- ios/OpenNotes/Info.plist | 2 +- scripts/backupEngine.test.mjs | 119 +++++++++++++++- src/hooks/useBackup.ts | 9 ++ src/services/backupEngine.ts | 176 +++++++++++++++++++++--- src/services/backupService.ts | 45 ++++-- 10 files changed, 407 insertions(+), 62 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 38d5d1b..d330c91 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -92,7 +92,7 @@ android { applicationId 'com.builderpro.opennotes' minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 10 + versionCode 11 versionName "1.3" buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" diff --git a/app.json b/app.json index 6aa3f65..ec263e9 100644 --- a/app.json +++ b/app.json @@ -16,7 +16,7 @@ "ios": { "bundleIdentifier": "com.builderpro.opennotes", "icon": "./assets/icon.png", - "buildNumber": "10", + "buildNumber": "11", "supportsTablet": true, "usesIcloudStorage": true, "entitlements": { @@ -65,7 +65,7 @@ }, "android": { "package": "com.builderpro.opennotes", - "versionCode": 10, + "versionCode": 11, "adaptiveIcon": { "foregroundImage": "./assets/adaptive-icon.png", "backgroundColor": "#F7F7F4" diff --git a/ios/OpenNotes.xcodeproj/project.pbxproj b/ios/OpenNotes.xcodeproj/project.pbxproj index da39e3b..d71e9f4 100644 --- a/ios/OpenNotes.xcodeproj/project.pbxproj +++ b/ios/OpenNotes.xcodeproj/project.pbxproj @@ -364,7 +364,7 @@ CODE_SIGN_ENTITLEMENTS = OpenNotes/OpenNotes.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 11; DEVELOPMENT_TEAM = U2CPXQV7AJ; ENABLE_BITCODE = NO; GCC_PREPROCESSOR_DEFINITIONS = ( @@ -403,7 +403,7 @@ CODE_SIGN_ENTITLEMENTS = OpenNotes/OpenNotes.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 11; DEVELOPMENT_TEAM = U2CPXQV7AJ; INFOPLIST_FILE = OpenNotes/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; diff --git a/ios/OpenNotes/ICloudBackupModule.m b/ios/OpenNotes/ICloudBackupModule.m index bc0ddd8..7aaf7ad 100644 --- a/ios/OpenNotes/ICloudBackupModule.m +++ b/ios/OpenNotes/ICloudBackupModule.m @@ -32,4 +32,9 @@ @interface RCT_EXTERN_MODULE(ICloudBackupModule, NSObject) resolver:(RCTPromiseResolveBlock)resolver rejecter:(RCTPromiseRejectBlock)rejecter) +RCT_EXTERN_METHOD(listCloudFiles:(NSString *)dir + timeoutMs:(nonnull NSNumber *)timeoutMs + resolver:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) + @end diff --git a/ios/OpenNotes/ICloudBackupModule.swift b/ios/OpenNotes/ICloudBackupModule.swift index ffc5887..500d2c7 100644 --- a/ios/OpenNotes/ICloudBackupModule.swift +++ b/ios/OpenNotes/ICloudBackupModule.swift @@ -159,9 +159,10 @@ class ICloudBackupModule: NSObject { return path } - /// Ensures a file in the ubiquity container is downloaded locally (iCloud - /// may have evicted it). Resolves true when the file is readable, false when - /// the download did not complete within the timeout. + /// Ensures a file in the ubiquity container is downloaded locally. Handles + /// all three states of a fresh install: file present, iCloud placeholder + /// present, or cloud metadata not yet synced (startDownloading is retried + /// until the metadata arrives). Resolves true when the file is readable. @objc func ensureDownloaded(_ path: String, timeoutMs: NSNumber, @@ -170,36 +171,86 @@ class ICloudBackupModule: NSObject { DispatchQueue.global(qos: .utility).async { let url = URL(fileURLWithPath: Self.plainPath(path)) let fileManager = FileManager.default - - if fileManager.fileExists(atPath: url.path) { - resolver(true) - return - } - - // A not-yet-downloaded ubiquitous file appears as "..icloud". - let placeholderName = ".\(url.lastPathComponent).icloud" - let placeholderUrl = url.deletingLastPathComponent().appendingPathComponent(placeholderName) - guard fileManager.fileExists(atPath: placeholderUrl.path) else { - resolver(false) - return - } - - do { - try fileManager.startDownloadingUbiquitousItem(at: url) - } catch { - rejecter("E_DOWNLOAD_START", "Could not start iCloud download for \(url.lastPathComponent)", error) - return - } - let deadline = Date().addingTimeInterval(timeoutMs.doubleValue / 1000.0) + while Date() < deadline { if fileManager.fileExists(atPath: url.path) { resolver(true) return } - Thread.sleep(forTimeInterval: 0.2) + // Registers download interest. Throws while the item's cloud metadata + // has not synced down yet - keep retrying until the deadline. + try? fileManager.startDownloadingUbiquitousItem(at: url) + Thread.sleep(forTimeInterval: 0.4) } - resolver(false) + resolver(fileManager.fileExists(atPath: url.path)) + } + } + + /// Lists every item iCloud knows about under the container's Documents dir + /// via NSMetadataQuery - the canonical discovery API. Unlike a directory + /// walk it sees items whose contents have not been downloaded yet, and + /// running it nudges the metadata sync on a fresh install. Returns + /// [{ rel, size, downloaded }] with `rel` relative to the given directory. + @objc + func listCloudFiles(_ dir: String, + timeoutMs: NSNumber, + resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock) { + DispatchQueue.main.async { + // Standardize through the /private symlink so prefix comparison cannot + // silently drop every result (/var vs /private/var). + let resolvedBase = URL(fileURLWithPath: Self.plainPath(dir)) + .resolvingSymlinksInPath().path + let basePath = resolvedBase.hasSuffix("/") ? resolvedBase : resolvedBase + "/" + let query = NSMetadataQuery() + query.searchScopes = [ + NSMetadataQueryUbiquitousDocumentsScope, + NSMetadataQueryUbiquitousDataScope, + ] + query.predicate = NSPredicate(format: "%K LIKE '*'", NSMetadataItemFSNameKey) + + var finished = false + var observer: NSObjectProtocol? + func complete() { + guard !finished else { return } + finished = true + query.disableUpdates() + query.stop() + if let obs = observer { NotificationCenter.default.removeObserver(obs) } + var out: [[String: Any]] = [] + for case let item as NSMetadataItem in query.results { + guard let rawPath = item.value(forAttribute: NSMetadataItemPathKey) as? String + else { continue } + let itemPath = URL(fileURLWithPath: rawPath).resolvingSymlinksInPath().path + guard itemPath.hasPrefix(basePath) else { continue } + // Cloud-only directories have no on-disk presence; classify by the + // metadata content type, falling back to the filesystem. + if let contentType = item.value( + forAttribute: NSMetadataItemContentTypeKey) as? String, + contentType == "public.folder" { continue } + var isDir: ObjCBool = false + if FileManager.default.fileExists(atPath: itemPath, isDirectory: &isDir), + isDir.boolValue { continue } + let rel = String(itemPath.dropFirst(basePath.count)) + let size = (item.value(forAttribute: NSMetadataItemFSSizeKey) as? NSNumber)?.intValue ?? 0 + let status = item.value( + forAttribute: NSMetadataUbiquitousItemDownloadingStatusKey) as? String + let downloaded = status == NSMetadataUbiquitousItemDownloadingStatusCurrent + || status == NSMetadataUbiquitousItemDownloadingStatusDownloaded + // size/downloaded are surfaced for diagnostics; JS keys off rel. + out.append(["rel": rel, "size": size, "downloaded": downloaded]) + } + resolver(out) + } + + observer = NotificationCenter.default.addObserver( + forName: .NSMetadataQueryDidFinishGathering, object: query, queue: .main + ) { _ in complete() } + DispatchQueue.main.asyncAfter( + deadline: .now() + timeoutMs.doubleValue / 1000.0 + ) { complete() } + query.start() } } } diff --git a/ios/OpenNotes/Info.plist b/ios/OpenNotes/Info.plist index a41742e..e8d6c72 100644 --- a/ios/OpenNotes/Info.plist +++ b/ios/OpenNotes/Info.plist @@ -54,7 +54,7 @@ CFBundleVersion - 10 + 11 ITSAppUsesNonExemptEncryption LSMinimumSystemVersion diff --git a/scripts/backupEngine.test.mjs b/scripts/backupEngine.test.mjs index ffe03c5..a2d546e 100644 --- a/scripts/backupEngine.test.mjs +++ b/scripts/backupEngine.test.mjs @@ -6,10 +6,13 @@ import { BACKUP_SUBDIR, checkRestoreAvailable, isSafeRelPath, + mergeBackupCatalog, noteIdForRel, restoreFromBackup, + resumeRestoreIfIncomplete, syncBackup, } from '../src/services/backupEngine.ts'; +import { RECOVERED_NOTE_TITLE } from '../src/services/catalogStore.ts'; const CONTAINER = '/icloud/Documents'; const BACKUP_DIR = `${CONTAINER}/${BACKUP_SUBDIR}`; @@ -424,7 +427,9 @@ test('restore copies everything, installs the catalog, and round-trips a full ba const result = await restoreFromBackup(fresh); assert.deepEqual(result, { status: 'ok', restored: 3 }); assert.equal(fresh.localFiles.get('notebook-bodies/note-a.body').contents, 'body-a'); - assert.equal(fresh.localCatalog, catalogRaw(['note-a'])); + const restoredCatalog = JSON.parse(fresh.localCatalog); + assert.deepEqual(restoredCatalog.notes.map((n) => n.id), ['note-a']); + assert.equal(restoredCatalog.notes[0].title, 'Title note-a'); }); 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 () assert.equal(env.localFiles.get('notebook-bodies/note-a.body').contents, 'local-version'); }); -test('EDGE: restore never overwrites a non-empty local catalog', async () => { +test('EDGE: restore merges into a non-empty local catalog without clobbering local notes', async () => { const env = makeEnv({ localCatalog: catalogRaw(['note-local']), backup: { @@ -449,7 +454,10 @@ test('EDGE: restore never overwrites a non-empty local catalog', async () => { }, }); await restoreFromBackup(env); - assert.equal(env.localCatalog, catalogRaw(['note-local'])); + const merged = JSON.parse(env.localCatalog); + const ids = merged.notes.map((n) => n.id).sort(); + assert.deepEqual(ids, ['note-backup', 'note-local']); + assert.equal(merged.notes.find((n) => n.id === 'note-local').title, 'Title note-local'); }); 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 assert.equal(env.localCatalog, null); }); +test('mergeBackupCatalog: stubs healed, user notes kept, backup-only added, tombstones respected', () => { + const local = JSON.parse(catalogRaw(['note-user', 'note-stub'])); + local.notes[1].title = RECOVERED_NOTE_TITLE; + local.deletedNoteIds = { 'note-deleted': '2026-08-30T00:00:00.000Z' }; + const backup = JSON.parse(catalogRaw(['note-stub', 'note-cloud-only', 'note-deleted'])); + const merged = mergeBackupCatalog(JSON.stringify(local), JSON.stringify(backup)); + const titles = Object.fromEntries(merged.notes.map((n) => [n.id, n.title])); + assert.equal(titles['note-user'], 'Title note-user'); + assert.equal(titles['note-stub'], 'Title note-stub'); + assert.equal(titles['note-cloud-only'], 'Title note-cloud-only'); + assert.equal('note-deleted' in titles, false); + assert.deepEqual(Object.keys(merged.deletedNoteIds), ['note-deleted']); +}); + +test('DEVICE REPRO: partial restore (cloud metadata race) resumes to completion on later launches', async () => { + // Fresh install on a real device: catalog synced fast, bodies were still + // cloud-only. The restore installed titles but no content. The resume pass + // must detect notes-without-bodies and pull them once downloadable. + const backup = { + [`${BACKUP_DIR}/${BACKUP_CATALOG_NAME}`]: catalogRaw(['note-a', 'note-b']), + [`${BACKUP_DIR}/${BACKUP_MANIFEST_NAME}`]: JSON.stringify({ + version: 1, + files: { + 'notebook-bodies/note-a.body': { size: 1, mtimeMs: 1 }, + 'notebook-bodies/note-b.body': { size: 1, mtimeMs: 1 }, + }, + lastBackupAt: 5, + noteCount: 2, + }), + [`${BACKUP_DIR}/notebook-bodies/note-a.body`]: 'body-a', + [`${BACKUP_DIR}/notebook-bodies/note-b.body`]: 'body-b', + }; + const undownloadable = new Set([ + 'notebook-bodies/note-a.body', + 'notebook-bodies/note-b.body', + ]); + const env = makeEnv({ backup, undownloadableRels: undownloadable }); + + const first = await restoreFromBackup(env); + assert.equal(first.status, 'partial'); + assert.equal(first.restored, 0); + // Titles arrived via catalog merge, content did not - the reported state. + assert.equal(JSON.parse(env.localCatalog).notes.length, 2); + assert.equal(env.localFiles.size, 0); + + // Next launch, iCloud metadata has synced: resume completes the restore. + undownloadable.clear(); + const resumed = await resumeRestoreIfIncomplete(env); + assert.equal(resumed.status, 'ok'); + assert.equal(resumed.restored, 2); + assert.equal(env.localFiles.get('notebook-bodies/note-a.body').contents, 'body-a'); + + // Fully healed: nothing further to resume. + assert.equal(await resumeRestoreIfIncomplete(env), null); +}); + +test('EDGE: resume does nothing for an empty library (prompt path owns that) or complete one', async () => { + const empty = makeEnv({ + backup: { [`${BACKUP_DIR}/notebook-bodies/note-a.body`]: 'a' }, + }); + assert.equal(await resumeRestoreIfIncomplete(empty), null); + + const complete = makeEnv({ + local: { 'notebook-bodies/note-a.body': localFile('a') }, + localCatalog: catalogRaw(['note-a']), + backup: { [`${BACKUP_DIR}/notebook-bodies/note-a.body`]: 'a' }, + }); + assert.equal(await resumeRestoreIfIncomplete(complete), null); +}); + +test('EDGE: a manifest-only rel (file gone from iCloud) never causes an endless resume loop', async () => { + const env = makeEnv({ + local: { 'notebook-bodies/note-a.body': localFile('a') }, + localCatalog: catalogRaw(['note-a', 'note-gone']), + backup: { + [`${BACKUP_DIR}/${BACKUP_MANIFEST_NAME}`]: JSON.stringify({ + version: 1, + files: { 'notebook-bodies/note-gone.body': { size: 1, mtimeMs: 1 } }, + lastBackupAt: 1, + noteCount: 2, + }), + }, + }); + // The file exists only in the stale manifest, not in any listing: gone. + assert.equal(await resumeRestoreIfIncomplete(env), null); +}); + +test('EDGE: stub titles heal from the backup catalog even when all bodies are present', async () => { + // The device race running the other way: bodies synced first, catalog was + // cloud-only during the first restore, reconciliation created stubs. + const local = JSON.parse(catalogRaw(['note-a'])); + local.notes[0].title = RECOVERED_NOTE_TITLE; + const env = makeEnv({ + local: { 'notebook-bodies/note-a.body': localFile('a') }, + localCatalog: JSON.stringify(local), + backup: { + [`${BACKUP_DIR}/${BACKUP_CATALOG_NAME}`]: catalogRaw(['note-a']), + [`${BACKUP_DIR}/notebook-bodies/note-a.body`]: 'a', + }, + }); + const result = await resumeRestoreIfIncomplete(env); + assert.equal(result.status, 'ok'); + assert.equal(JSON.parse(env.localCatalog).notes[0].title, 'Title note-a'); +}); + test('EDGE: an env that throws unexpectedly yields partial, not a crash', async () => { const env = makeEnv({ local: { 'notebook-bodies/a.body': localFile('a') } }); env.listLocalDataFiles = async () => { diff --git a/src/hooks/useBackup.ts b/src/hooks/useBackup.ts index 7fdfef4..864f041 100644 --- a/src/hooks/useBackup.ts +++ b/src/hooks/useBackup.ts @@ -6,6 +6,7 @@ import { isBackupAvailable, isBackupEnabled, performBackupRestore, + resumeBackupRestoreIfIncomplete, scheduleBackupSync, setBackupEnabled, } from '../services/backupService'; @@ -65,6 +66,14 @@ export function useBackup( if (restorePromptShownRef.current) return; const availability = await checkBackupRestoreAvailable(); if (!availability || restorePromptShownRef.current) { + // Heal any interrupted restore first: notes whose titles arrived but + // whose content files are still only in iCloud (a fresh install can + // race the metadata sync). Copies only what is missing; silent. + const resumed = await resumeBackupRestoreIfIncomplete(); + if (resumed && resumed.status !== 'unavailable' && resumed.restored > 0) { + await catalogStore.invalidate(); + await refreshLibrary(); + } // No restore pending: safe to start the catch-up push. It retries a // backup that failed last session and mirrors pre-existing data after // an app update; a no-op copy-wise when the mirror is current. It is diff --git a/src/services/backupEngine.ts b/src/services/backupEngine.ts index 59b3725..b8fc67a 100644 --- a/src/services/backupEngine.ts +++ b/src/services/backupEngine.ts @@ -8,7 +8,7 @@ // Sync pushes local changes to the mirror. Restore pulls the mirror into an // EMPTY library (never over existing notes) - after which the catalog's own // reconciliation pass rebuilds anything the mirrored catalog missed. -import { parseCatalog } from './catalogStore.ts'; +import { RECOVERED_NOTE_TITLE, parseCatalog, type Catalog } from './catalogStore.ts'; export const BACKUP_SUBDIR = 'OpenNotesBackup'; export const BACKUP_MANIFEST_NAME = 'manifest.json'; @@ -145,6 +145,36 @@ function parseManifest(raw: string | null, warn: BackupEnv['warn']): BackupManif } } +const META_DOWNLOAD_TIMEOUT_MS = 10000; + +/** + * Reads a backup-side JSON file, forcing an iCloud download first - on a + * fresh install the manifest/catalog can be cloud-only just like data files. + * A failed download degrades to null, which every caller already handles. + */ +async function readBackupMeta( + env: BackupEnv, + backupDir: string, + name: string, +): Promise { + const abs = `${backupDir}/${name}`; + try { + await env.ensureDownloaded(abs, META_DOWNLOAD_TIMEOUT_MS); + } catch { + // Best effort; the read below returns null if the file never landed. + } + return env.readBackupFile(abs); +} + +async function backupDataRels( + env: BackupEnv, + backupDir: string, + manifest: BackupManifest | null, +): Promise<{ rels: Set; listed: Set }> { + const listed = new Set(await env.listBackupDataFiles(backupDir)); + return { rels: new Set([...Object.keys(manifest?.files ?? {}), ...listed]), listed }; +} + function localNoteCount(catalogRaw: string | null): number | null { if (!catalogRaw) return null; const catalog = parseCatalog(catalogRaw); @@ -293,11 +323,11 @@ export async function checkRestoreAvailable(env: BackupEnv): Promise [n.id, n])); + const backupById = new Map(backup.notes.map((n) => [n.id, n])); + const notes = local.notes.map((note) => { + const backupNote = backupById.get(note.id); + if (backupNote && note.title === RECOVERED_NOTE_TITLE) { + changed = true; + return backupNote; + } + return note; + }); + for (const backupNote of backup.notes) { + if (!localById.has(backupNote.id) && !local.deletedNoteIds[backupNote.id]) { + notes.push(backupNote); + changed = true; + } + } + + const localFolderIds = new Set(local.folders.map((f) => f.id)); + const folders = [...local.folders]; + for (const backupFolder of backup.folders) { + if (!localFolderIds.has(backupFolder.id)) { + folders.push(backupFolder); + changed = true; + } + } + + if (!changed) return null; + return { version: 1, notes, folders, deletedNoteIds: local.deletedNoteIds }; +} + /** * Copies every backup data file into the local Documents dir (skipping any - * that already exist locally), then installs the backup catalog if the local - * one is still empty. Files that fail to download or copy are counted, not - * fatal - catalog reconciliation recovers whatever did arrive. + * that already exist locally), then merges the backup catalog into the local + * one. Files that fail to download or copy are counted, not fatal - the + * restore is resumable: a later run copies only what is still missing. */ export async function restoreFromBackup(env: BackupEnv): Promise { try { @@ -331,11 +411,10 @@ export async function restoreFromBackup(env: BackupEnv): Promise const backupDir = backupDirPath(containerDir); const manifest = parseManifest( - await env.readBackupFile(`${backupDir}/${BACKUP_MANIFEST_NAME}`), + await readBackupMeta(env, backupDir, BACKUP_MANIFEST_NAME), env.warn, ); - const listed = await env.listBackupDataFiles(backupDir); - const rels = new Set([...Object.keys(manifest?.files ?? {}), ...listed]); + const { rels } = await backupDataRels(env, backupDir, manifest); let restored = 0; let failed = 0; @@ -362,16 +441,14 @@ export async function restoreFromBackup(env: BackupEnv): Promise } } - const backupCatalogRaw = await env.readBackupFile(`${backupDir}/${BACKUP_CATALOG_NAME}`); - if (backupCatalogRaw && parseCatalog(backupCatalogRaw)) { - const localCount = localNoteCount(await env.readLocalCatalog()); - if (localCount === null || localCount === 0) { - const ok = await env.writeLocalCatalog(backupCatalogRaw); - if (!ok) { - // Not counted as user-visible failure: reconciliation rebuilds the - // catalog from the restored body files on next load. - env.warn('[backupEngine] local catalog install failed; relying on reconciliation'); - } + const backupCatalogRaw = await readBackupMeta(env, backupDir, BACKUP_CATALOG_NAME); + const merged = mergeBackupCatalog(await env.readLocalCatalog(), backupCatalogRaw); + if (merged) { + const ok = await env.writeLocalCatalog(JSON.stringify(merged)); + if (!ok) { + // Not counted as user-visible failure: reconciliation rebuilds the + // catalog from the restored body files on next load. + env.warn('[backupEngine] local catalog merge write failed; relying on reconciliation'); } } @@ -382,3 +459,64 @@ export async function restoreFromBackup(env: BackupEnv): Promise return { status: 'partial', restored: 0, failed: 1 }; } } + +/** + * Detects and completes an interrupted restore: backup data files that are + * still missing locally for notes the local catalog knows about (the state a + * partial restore leaves behind - titles present, content absent). Returns + * null when there is nothing to resume. Never throws. + */ +export async function resumeRestoreIfIncomplete(env: BackupEnv): Promise { + try { + if (!(await env.isEnabled())) return null; + const containerDir = await env.getContainerDir(); + if (!containerDir) return null; + const backupDir = backupDirPath(containerDir); + + const localCatalog = parseCatalog((await env.readLocalCatalog()) ?? ''); + if (!localCatalog || localCatalog.notes.length === 0) return null; + const localIds = new Set(localCatalog.notes.map((n) => n.id)); + + const manifest = parseManifest( + await readBackupMeta(env, backupDir, BACKUP_MANIFEST_NAME), + env.warn, + ); + const { listed } = await backupDataRels(env, backupDir, manifest); + + // Only files iCloud still claims to have count as pending - a rel that + // exists solely in a stale manifest is gone, not downloadable, and must + // not put the resume path into a retry-forever loop. + let resumable = false; + for (const rel of listed) { + if (!isSafeRelPath(rel)) continue; + const noteId = noteIdForRel(rel); + if (!noteId || !localIds.has(noteId)) continue; + if (!(await env.localFileExists(rel))) { + resumable = true; + break; + } + } + + // Also heal metadata-only damage: recovery stubs whose real titles are + // still in the backup catalog (the race running the other way - bodies + // synced first, catalog was cloud-only during the first restore). + if (!resumable && localCatalog.notes.some((n) => n.title === RECOVERED_NOTE_TITLE)) { + const backupCatalog = parseCatalog( + (await readBackupMeta(env, backupDir, BACKUP_CATALOG_NAME)) ?? '', + ); + if (backupCatalog) { + const backupIds = new Set(backupCatalog.notes.map((n) => n.id)); + resumable = localCatalog.notes.some( + (n) => n.title === RECOVERED_NOTE_TITLE && backupIds.has(n.id), + ); + } + } + + if (!resumable) return null; + env.warn('[backupEngine] resuming incomplete restore'); + return await restoreFromBackup(env); + } catch (error) { + env.warn('[backupEngine] resume check failed', error); + return null; + } +} diff --git a/src/services/backupService.ts b/src/services/backupService.ts index 0b94701..5e9c1fb 100644 --- a/src/services/backupService.ts +++ b/src/services/backupService.ts @@ -6,6 +6,7 @@ import { DATA_DIRS, checkRestoreAvailable, restoreFromBackup, + resumeRestoreIfIncomplete, syncBackup, type BackupEnv, type BackupFileInfo, @@ -33,6 +34,10 @@ type ICloudBackupModuleType = { listFilesRecursive?: ( dir: string, ) => Promise>; + listCloudFiles?: ( + dir: string, + timeoutMs: number, + ) => Promise>; }; const ICloudBackupModule = NativeModules.ICloudBackupModule as @@ -209,16 +214,32 @@ const env: BackupEnv = { }, async listBackupDataFiles(backupDir: string): Promise { - if (!ICloudBackupModule?.listFilesRecursive) return []; - const out: string[] = []; - for (const dir of DATA_DIRS) { - const entries = await ICloudBackupModule.listFilesRecursive(`${backupDir}/${dir}`); - for (const entry of entries) { - if (entry.rel.endsWith('.tmp')) continue; - out.push(normalizeICloudPlaceholder(`${dir}/${entry.rel}`)); + const out = new Set(); + // The filesystem walk sees materialized files (and the dev override dir). + if (ICloudBackupModule?.listFilesRecursive) { + for (const dir of DATA_DIRS) { + const entries = await ICloudBackupModule.listFilesRecursive(`${backupDir}/${dir}`); + for (const entry of entries) { + if (entry.rel.endsWith('.tmp')) continue; + out.add(normalizeICloudPlaceholder(`${dir}/${entry.rel}`)); + } } } - return out; + // The metadata query sees items iCloud knows about but has not yet + // synced to disk - the fresh-install state - and nudges the sync along. + if (ICloudBackupModule?.listCloudFiles) { + try { + const cloud = await ICloudBackupModule.listCloudFiles(backupDir, 15000); + for (const entry of cloud) { + const rel = normalizeICloudPlaceholder(entry.rel); + if (rel.endsWith('.tmp')) continue; + if (DATA_DIRS.some((d) => rel.startsWith(`${d}/`))) out.add(rel); + } + } catch (error) { + env.warn('[backupService] cloud metadata listing failed', error); + } + } + return [...out]; }, async ensureDownloaded(absPath: string, timeoutMs: number): Promise { @@ -324,6 +345,14 @@ export function performBackupRestore(): Promise { return restoreFromBackup(env); } +/** + * Completes an interrupted restore (notes known locally whose content files + * are still only in the backup). Returns null when nothing was missing. + */ +export function resumeBackupRestoreIfIncomplete(): Promise { + return resumeRestoreIfIncomplete(env); +} + // Timers do not fire while backgrounded; push any pending sync out before the // app is suspended, mirroring the autosave hook's behavior. AppState.addEventListener('change', (state) => {