From e9eb8c846a038acbae196653ca91b9a699e7684f Mon Sep 17 00:00:00 2001 From: markm39 Date: Sun, 30 Aug 2026 01:07:32 -0500 Subject: [PATCH] feat(backup): automatic iCloud Drive backup with restore after app deletion Notes now mirror to the user's own iCloud Drive container so deleting the app no longer destroys them. No account, no server, no sync UI - disk stays the source of truth and the mirror follows it, in keeping with local-first. - Native ICloudBackupModule (Swift): ubiquity container resolution, evicted-file download, and generic file ops - expo-file-system cannot reach paths outside the app sandbox, which includes the container. - Pure backup engine (backupEngine.ts) with injected env, node-tested: debounced mirror sync (changed files only, one-call native directory listing), manifest with size/mtime diffing, and restore into an empty library with per-path safety validation restricted to the data dirs. - Deletions propagate ONLY via catalog tombstones (deletedNoteIds, pruned after 90 days). A file merely missing locally - fresh install, partial restore, older device - never deletes its backup copy, and an empty local library never overwrites a backup that has notes. This guard exists because simulator E2E caught the reinstall race where a startup sync wiped the backup while the restore prompt was on screen. - Restore-on-reinstall: when the library is empty and the backup has notes, the library offers a one-tap restore; catalog reconciliation recovers anything the mirrored catalog missed. - Default on, with an off switch (confirmed, destructive-styled) and an honest status line in the support sheet; hidden on Android until a backup implementation exists there. iCloud signed-out degrades to a visible 'unavailable' state and self-heals when it appears. - Dev-only container override enables full E2E: verified on iPad simulator - first mirror, change propagation, delete-app/reinstall/ restore, tombstoned delete, toggle off/on with catch-up, corrupt manifest self-heal, unavailable, and the backup-wipe regression. Claude-Session: https://claude.ai/code/session_01Q73aeGnnUsUZ7BLLXJq2u5 --- app.json | 10 + app/index.tsx | 7 + ios/OpenNotes.xcodeproj/project.pbxproj | 8 + ios/OpenNotes/ICloudBackupModule.m | 35 ++ ios/OpenNotes/ICloudBackupModule.swift | 205 +++++++++ ios/OpenNotes/OpenNotes.entitlements | 17 +- package.json | 3 +- scripts/backupEngine.test.mjs | 489 ++++++++++++++++++++++ src/components/library/OpenNotesSheet.tsx | 54 ++- src/hooks/useBackup.ts | 145 +++++++ src/services/backupEngine.ts | 384 +++++++++++++++++ src/services/backupService.ts | 314 ++++++++++++++ src/services/catalogEnv.ts | 20 +- src/services/catalogStore.ts | 63 ++- src/services/notesRepo.ts | 13 +- 15 files changed, 1751 insertions(+), 16 deletions(-) create mode 100644 ios/OpenNotes/ICloudBackupModule.m create mode 100644 ios/OpenNotes/ICloudBackupModule.swift create mode 100644 scripts/backupEngine.test.mjs create mode 100644 src/hooks/useBackup.ts create mode 100644 src/services/backupEngine.ts create mode 100644 src/services/backupService.ts diff --git a/app.json b/app.json index 964a34a..b301c97 100644 --- a/app.json +++ b/app.json @@ -18,6 +18,16 @@ "icon": "./assets/icon.png", "buildNumber": "9", "supportsTablet": true, + "usesIcloudStorage": true, + "entitlements": { + "com.apple.developer.icloud-container-identifiers": [ + "iCloud.com.builderpro.opennotes" + ], + "com.apple.developer.icloud-services": ["CloudDocuments"], + "com.apple.developer.ubiquity-container-identifiers": [ + "iCloud.com.builderpro.opennotes" + ] + }, "infoPlist": { "ITSAppUsesNonExemptEncryption": false, "NSPhotoLibraryUsageDescription": "OpenNotes needs access to your photo library so you can insert images into your notes.", diff --git a/app/index.tsx b/app/index.tsx index 06abcd2..6e80a9f 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -23,6 +23,7 @@ import { CommunityInviteSheet } from '../src/components/library/CommunityInviteS import { OpenNotesSheet } from '../src/components/library/OpenNotesSheet'; import { LibrarySection } from '../src/components/library/LibrarySection'; import { useOnboarding } from '../src/hooks/useOnboarding'; +import { useBackup } from '../src/hooks/useBackup'; import { useLibrarySupport } from '../src/hooks/useLibrarySupport'; import { useTheme } from '../src/hooks/useTheme'; import { spacing } from '../src/theme/spacing'; @@ -89,6 +90,8 @@ export default function LibraryScreen() { () => setAction({ kind: 'community' }), [], ); + const { backupSupported, backupEnabled, backupSubtitle, toggleBackup } = + useBackup(refresh); const { dismissCommunity, joinCommunity, rateOpenNotes } = useLibrarySupport({ canShowAutomaticPrompt: onboarding.ready && !onboarding.visible && action === null, @@ -312,6 +315,10 @@ export default function LibraryScreen() { setAction(null); onboarding.show(); }} + backupSupported={backupSupported} + backupEnabled={backupEnabled} + backupSubtitle={backupSubtitle} + onToggleBackup={toggleBackup} /> + +@interface RCT_EXTERN_MODULE(ICloudBackupModule, NSObject) + +RCT_EXTERN_METHOD(getContainerPath:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) + +RCT_EXTERN_METHOD(copyItem:(NSString *)from + to:(NSString *)to + resolver:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) + +RCT_EXTERN_METHOD(writeFileAtomic:(NSString *)path + contents:(NSString *)contents + resolver:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) + +RCT_EXTERN_METHOD(readFileAsString:(NSString *)path + resolver:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) + +RCT_EXTERN_METHOD(deleteItem:(NSString *)path + resolver:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) + +RCT_EXTERN_METHOD(listFilesRecursive:(NSString *)dir + resolver:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) + +RCT_EXTERN_METHOD(ensureDownloaded:(NSString *)path + timeoutMs:(nonnull NSNumber *)timeoutMs + resolver:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) + +@end diff --git a/ios/OpenNotes/ICloudBackupModule.swift b/ios/OpenNotes/ICloudBackupModule.swift new file mode 100644 index 0000000..ffc5887 --- /dev/null +++ b/ios/OpenNotes/ICloudBackupModule.swift @@ -0,0 +1,205 @@ +import Foundation +import React + +@objc(ICloudBackupModule) +class ICloudBackupModule: NSObject { + @objc static func requiresMainQueueSetup() -> Bool { + return false + } + + /// Resolves the app's iCloud Drive container Documents directory, creating + /// it if needed. Resolves null when iCloud is unavailable (signed out, iCloud + /// Drive disabled, or missing entitlement) - callers treat that as "backup + /// unavailable", never as an error. + @objc + func getContainerPath(_ resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock) { + DispatchQueue.global(qos: .utility).async { + guard let containerUrl = FileManager.default.url(forUbiquityContainerIdentifier: nil) else { + resolver(NSNull()) + return + } + let documentsUrl = containerUrl.appendingPathComponent("Documents", isDirectory: true) + do { + try FileManager.default.createDirectory( + at: documentsUrl, + withIntermediateDirectories: true + ) + } catch { + rejecter("E_CONTAINER_DIR", "Could not create iCloud Documents directory", error) + return + } + resolver(documentsUrl.path) + } + } + + /// File operations below use FileManager because expo-file-system refuses + /// paths outside the app sandbox scopes - which includes the ubiquity + /// container. All backup-side I/O must therefore go through this module. + + @objc + func copyItem(_ from: String, to: String, + resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock) { + DispatchQueue.global(qos: .utility).async { + let fromUrl = URL(fileURLWithPath: Self.plainPath(from)) + let toUrl = URL(fileURLWithPath: Self.plainPath(to)) + do { + try FileManager.default.createDirectory( + at: toUrl.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + if FileManager.default.fileExists(atPath: toUrl.path) { + try FileManager.default.removeItem(at: toUrl) + } + try FileManager.default.copyItem(at: fromUrl, to: toUrl) + resolver(true) + } catch { + rejecter("E_COPY", "Copy failed: \(fromUrl.lastPathComponent)", error) + } + } + } + + @objc + func writeFileAtomic(_ path: String, contents: String, + resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock) { + DispatchQueue.global(qos: .utility).async { + let url = URL(fileURLWithPath: Self.plainPath(path)) + do { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try contents.write(to: url, atomically: true, encoding: .utf8) + resolver(true) + } catch { + rejecter("E_WRITE", "Write failed: \(url.lastPathComponent)", error) + } + } + } + + @objc + func readFileAsString(_ path: String, + resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock) { + DispatchQueue.global(qos: .utility).async { + let url = URL(fileURLWithPath: Self.plainPath(path)) + guard FileManager.default.fileExists(atPath: url.path) else { + resolver(NSNull()) + return + } + do { + resolver(try String(contentsOf: url, encoding: .utf8)) + } catch { + rejecter("E_READ", "Read failed: \(url.lastPathComponent)", error) + } + } + } + + @objc + func deleteItem(_ path: String, + resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock) { + DispatchQueue.global(qos: .utility).async { + let url = URL(fileURLWithPath: Self.plainPath(path)) + do { + if FileManager.default.fileExists(atPath: url.path) { + try FileManager.default.removeItem(at: url) + } + resolver(true) + } catch { + rejecter("E_DELETE", "Delete failed: \(url.lastPathComponent)", error) + } + } + } + + /// Lists every regular file under `dir` recursively. Returns + /// [{ rel, size, mtimeMs }] with `rel` relative to `dir`. + @objc + func listFilesRecursive(_ dir: String, + resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock) { + DispatchQueue.global(qos: .utility).async { + let baseUrl = URL(fileURLWithPath: Self.plainPath(dir)) + guard FileManager.default.fileExists(atPath: baseUrl.path) else { + resolver([]) + return + } + guard let enumerator = FileManager.default.enumerator( + at: baseUrl, + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey] + ) else { + resolver([]) + return + } + var out: [[String: Any]] = [] + let basePath = baseUrl.path.hasSuffix("/") ? baseUrl.path : baseUrl.path + "/" + for case let fileUrl as URL in enumerator { + guard let values = try? fileUrl.resourceValues( + forKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey] + ), values.isRegularFile == true else { continue } + guard fileUrl.path.hasPrefix(basePath) else { continue } + let rel = String(fileUrl.path.dropFirst(basePath.count)) + let mtimeMs = (values.contentModificationDate?.timeIntervalSince1970 ?? 0) * 1000 + out.append([ + "rel": rel, + "size": values.fileSize ?? 0, + "mtimeMs": Int(mtimeMs.rounded()), + ]) + } + resolver(out) + } + } + + private static func plainPath(_ path: String) -> String { + if path.hasPrefix("file://"), let url = URL(string: path) { + return url.path + } + 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. + @objc + func ensureDownloaded(_ path: String, + timeoutMs: NSNumber, + resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock) { + 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) + } + resolver(false) + } + } +} diff --git a/ios/OpenNotes/OpenNotes.entitlements b/ios/OpenNotes/OpenNotes.entitlements index f683276..44c1c5f 100644 --- a/ios/OpenNotes/OpenNotes.entitlements +++ b/ios/OpenNotes/OpenNotes.entitlements @@ -1,5 +1,18 @@ - - \ No newline at end of file + + com.apple.developer.icloud-container-identifiers + + iCloud.com.builderpro.opennotes + + com.apple.developer.icloud-services + + CloudDocuments + + com.apple.developer.ubiquity-container-identifiers + + iCloud.com.builderpro.opennotes + + + diff --git a/package.json b/package.json index 6e7c7be..62943d3 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "android": "expo run:android", "test:lifecycle": "node --experimental-strip-types --test scripts/lifecyclePolicy.test.mjs", "test:catalog": "node --experimental-strip-types --test scripts/catalogStore.test.mjs", - "test": "node --experimental-strip-types --test scripts/lifecyclePolicy.test.mjs scripts/catalogStore.test.mjs", + "test": "node --experimental-strip-types --test scripts/lifecyclePolicy.test.mjs scripts/catalogStore.test.mjs scripts/backupEngine.test.mjs", + "test:backup": "node --experimental-strip-types --test scripts/backupEngine.test.mjs", "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { diff --git a/scripts/backupEngine.test.mjs b/scripts/backupEngine.test.mjs new file mode 100644 index 0000000..ffe03c5 --- /dev/null +++ b/scripts/backupEngine.test.mjs @@ -0,0 +1,489 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + BACKUP_CATALOG_NAME, + BACKUP_MANIFEST_NAME, + BACKUP_SUBDIR, + checkRestoreAvailable, + isSafeRelPath, + noteIdForRel, + restoreFromBackup, + syncBackup, +} from '../src/services/backupEngine.ts'; + +const CONTAINER = '/icloud/Documents'; +const BACKUP_DIR = `${CONTAINER}/${BACKUP_SUBDIR}`; + +function catalogRaw(noteIds, deletedNoteIds = {}) { + return JSON.stringify({ + version: 1, + deletedNoteIds, + notes: noteIds.map((id) => ({ + id, + title: `Title ${id}`, + folderId: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + backgroundType: 'plain', + pdfUri: null, + thumbnailUri: null, + })), + folders: [], + }); +} + +/** + * Fake environment backed by two in-memory file maps: + * - local: rel path -> { contents, size, mtimeMs } + * - backup: abs path -> contents (data files store the local contents string) + */ +function makeEnv({ + enabled = true, + container = CONTAINER, + local = {}, + backup = {}, + localCatalog = null, + failBackupWrites = false, + failCopyRels = new Set(), + undownloadableRels = new Set(), +} = {}) { + const localFiles = new Map(Object.entries(local)); + const backupFiles = new Map(Object.entries(backup)); + const env = { + localFiles, + backupFiles, + localCatalog, + warnings: [], + async getContainerDir() { + return container; + }, + async isEnabled() { + return enabled; + }, + async readLocalCatalog() { + return env.localCatalog; + }, + async writeLocalCatalog(raw) { + env.localCatalog = raw; + return true; + }, + async listLocalDataFiles() { + return [...localFiles.entries()].map(([rel, f]) => ({ + rel, + size: f.size, + mtimeMs: f.mtimeMs, + })); + }, + async localFileExists(rel) { + return localFiles.has(rel); + }, + async copyLocalToBackup(rel, backupDir) { + if (failBackupWrites || failCopyRels.has(rel)) return false; + const file = localFiles.get(rel); + if (!file) return false; + backupFiles.set(`${backupDir}/${rel}`, file.contents); + return true; + }, + async copyBackupToLocal(backupDir, rel) { + const contents = backupFiles.get(`${backupDir}/${rel}`); + if (contents === undefined) return false; + localFiles.set(rel, { contents, size: contents.length, mtimeMs: 1 }); + return true; + }, + async readBackupFile(abs) { + return backupFiles.has(abs) ? backupFiles.get(abs) : null; + }, + async writeBackupFileAtomic(abs, contents) { + if (failBackupWrites) return false; + backupFiles.set(abs, contents); + return true; + }, + async deleteBackupFile(abs) { + backupFiles.delete(abs); + }, + async listBackupDataFiles(backupDir) { + const prefix = `${backupDir}/`; + return [...backupFiles.keys()] + .filter( + (abs) => + abs.startsWith(prefix) && + !abs.endsWith(BACKUP_MANIFEST_NAME) && + !abs.endsWith(BACKUP_CATALOG_NAME), + ) + .map((abs) => abs.slice(prefix.length)); + }, + async ensureDownloaded(abs) { + const prefix = `${BACKUP_DIR}/`; + const rel = abs.startsWith(prefix) ? abs.slice(prefix.length) : abs; + return backupFiles.has(abs) && !undownloadableRels.has(rel); + }, + now() { + return 1756400000000; + }, + warn(message) { + env.warnings.push(message); + }, + }; + return env; +} + +function localFile(contents, mtimeMs = 100) { + return { contents, size: contents.length, mtimeMs }; +} + +function manifestIn(env) { + return JSON.parse(env.backupFiles.get(`${BACKUP_DIR}/${BACKUP_MANIFEST_NAME}`)); +} + +test('first sync mirrors every data file plus catalog and manifest', async () => { + const env = makeEnv({ + local: { + 'notebook-bodies/note-a.body': localFile('body-a'), + 'pdfs/note-a.pdf': localFile('pdf-a'), + 'images/note-a/img1.png': localFile('img-1'), + }, + localCatalog: catalogRaw(['note-a']), + }); + const result = await syncBackup(env); + assert.deepEqual(result, { status: 'ok', copied: 3, removed: 0 }); + assert.equal(env.backupFiles.get(`${BACKUP_DIR}/notebook-bodies/note-a.body`), 'body-a'); + assert.equal(env.backupFiles.get(`${BACKUP_DIR}/${BACKUP_CATALOG_NAME}`), catalogRaw(['note-a'])); + const manifest = manifestIn(env); + assert.equal(manifest.noteCount, 1); + assert.equal(Object.keys(manifest.files).length, 3); +}); + +test('unchanged files are not re-copied on the next sync', async () => { + const env = makeEnv({ + local: { 'notebook-bodies/note-a.body': localFile('body-a') }, + localCatalog: catalogRaw(['note-a']), + }); + await syncBackup(env); + const second = await syncBackup(env); + assert.deepEqual(second, { status: 'ok', copied: 0, removed: 0 }); +}); + +test('a changed file (same size, new mtime) is re-copied', async () => { + const env = makeEnv({ + local: { 'notebook-bodies/note-a.body': localFile('body-a', 100) }, + localCatalog: catalogRaw(['note-a']), + }); + await syncBackup(env); + env.localFiles.set('notebook-bodies/note-a.body', localFile('body-b', 200)); + const result = await syncBackup(env); + assert.deepEqual(result, { status: 'ok', copied: 1, removed: 0 }); + assert.equal(env.backupFiles.get(`${BACKUP_DIR}/notebook-bodies/note-a.body`), 'body-b'); +}); + +test('a tombstoned (user-deleted) note is removed from the backup', async () => { + const env = makeEnv({ + local: { + 'notebook-bodies/note-a.body': localFile('body-a'), + 'notebook-bodies/note-b.body': localFile('body-b'), + }, + localCatalog: catalogRaw(['note-a', 'note-b']), + }); + await syncBackup(env); + env.localFiles.delete('notebook-bodies/note-b.body'); + env.localCatalog = catalogRaw(['note-a'], { 'note-b': '2026-08-30T00:00:00.000Z' }); + const result = await syncBackup(env); + assert.deepEqual(result, { status: 'ok', copied: 0, removed: 1 }); + assert.equal(env.backupFiles.has(`${BACKUP_DIR}/notebook-bodies/note-b.body`), false); + assert.equal(manifestIn(env).noteCount, 1); +}); + +test('DISASTER REPRO: an empty local library never wipes a backup that has notes', async () => { + // The exact simulator-caught race: fresh install after app deletion, the + // restore prompt is still on screen, and a sync runs. It must refuse. + const source = makeEnv({ + local: { 'notebook-bodies/note-a.body': localFile('body-a') }, + localCatalog: catalogRaw(['note-a']), + }); + await syncBackup(source); + + const freshInstall = makeEnv({ backup: Object.fromEntries(source.backupFiles) }); + const result = await syncBackup(freshInstall); + assert.deepEqual(result, { status: 'skipped-empty-local' }); + assert.equal( + freshInstall.backupFiles.get(`${BACKUP_DIR}/notebook-bodies/note-a.body`), + 'body-a', + ); + assert.equal( + freshInstall.backupFiles.get(`${BACKUP_DIR}/${BACKUP_CATALOG_NAME}`), + catalogRaw(['note-a']), + ); + // And the restore must still be offered afterwards. + const offer = await checkRestoreAvailable(freshInstall); + assert.equal(offer.noteCount, 1); +}); + +test('EDGE: a file missing locally WITHOUT a tombstone is preserved in the backup', async () => { + // "Not now" after reinstall, then the user creates a new note: the old + // backup bodies have no tombstones and must survive the next sync. + const source = makeEnv({ + local: { + 'notebook-bodies/note-old1.body': localFile('old-1'), + 'notebook-bodies/note-old2.body': localFile('old-2'), + 'pdfs/note-old1.pdf': localFile('pdf-old'), + }, + localCatalog: catalogRaw(['note-old1', 'note-old2']), + }); + await syncBackup(source); + + const afterReinstall = makeEnv({ + backup: Object.fromEntries(source.backupFiles), + local: { 'notebook-bodies/note-new.body': localFile('new') }, + localCatalog: catalogRaw(['note-new']), + }); + const result = await syncBackup(afterReinstall); + assert.deepEqual(result, { status: 'ok', copied: 1, removed: 0 }); + assert.equal( + afterReinstall.backupFiles.get(`${BACKUP_DIR}/notebook-bodies/note-old1.body`), + 'old-1', + ); + assert.equal( + afterReinstall.backupFiles.get(`${BACKUP_DIR}/pdfs/note-old1.pdf`), + 'pdf-old', + ); + assert.equal( + afterReinstall.backupFiles.get(`${BACKUP_DIR}/notebook-bodies/note-new.body`), + 'new', + ); + // The preserved files stay in the manifest so a later restore finds them. + const manifest = manifestIn(afterReinstall); + assert.ok(manifest.files['notebook-bodies/note-old1.body']); + assert.ok(manifest.files['notebook-bodies/note-new.body']); +}); + +test('noteIdForRel maps every mirrored layout back to its note id', () => { + assert.equal(noteIdForRel('notebook-bodies/note-a.body'), 'note-a'); + assert.equal(noteIdForRel('pdfs/note-a.pdf'), 'note-a'); + assert.equal(noteIdForRel('images/note-a/img1.png'), 'note-a'); + assert.equal(noteIdForRel('notebook-bodies/note%20b.body'), 'note b'); + assert.equal(noteIdForRel('unknown/file.txt'), null); + assert.equal(noteIdForRel('manifest.json'), null); +}); + +test('EDGE: backup disabled does nothing', async () => { + const env = makeEnv({ + enabled: false, + local: { 'notebook-bodies/note-a.body': localFile('a') }, + }); + assert.deepEqual(await syncBackup(env), { status: 'disabled' }); + assert.equal(env.backupFiles.size, 0); +}); + +test('EDGE: iCloud unavailable reports unavailable and never throws', async () => { + const env = makeEnv({ + container: null, + local: { 'notebook-bodies/note-a.body': localFile('a') }, + }); + assert.deepEqual(await syncBackup(env), { status: 'unavailable' }); +}); + +test('EDGE: iCloud appearing later catches up with a full mirror', async () => { + const env = makeEnv({ + container: null, + local: { 'notebook-bodies/note-a.body': localFile('a') }, + localCatalog: catalogRaw(['note-a']), + }); + assert.equal((await syncBackup(env)).status, 'unavailable'); + env.getContainerDir = async () => CONTAINER; + const result = await syncBackup(env); + assert.deepEqual(result, { status: 'ok', copied: 1, removed: 0 }); +}); + +test('EDGE: a copy failure is partial, keeps the stale mirror entry, and retries next sync', async () => { + const failCopyRels = new Set(['notebook-bodies/note-b.body']); + const env = makeEnv({ + local: { + 'notebook-bodies/note-a.body': localFile('a'), + 'notebook-bodies/note-b.body': localFile('b'), + }, + localCatalog: catalogRaw(['note-a', 'note-b']), + failCopyRels, + }); + const first = await syncBackup(env); + assert.equal(first.status, 'partial'); + assert.equal(first.failed, 1); + failCopyRels.clear(); + const second = await syncBackup(env); + assert.deepEqual(second, { status: 'ok', copied: 1, removed: 0 }); + assert.equal(env.backupFiles.get(`${BACKUP_DIR}/notebook-bodies/note-b.body`), 'b'); +}); + +test('EDGE: total backup write failure never affects local data and reports partial', async () => { + const env = makeEnv({ + local: { 'notebook-bodies/note-a.body': localFile('a') }, + localCatalog: catalogRaw(['note-a']), + failBackupWrites: true, + }); + const result = await syncBackup(env); + assert.equal(result.status, 'partial'); + assert.equal(env.localCatalog, catalogRaw(['note-a'])); +}); + +test('EDGE: corrupt backup manifest triggers a full harmless re-mirror', async () => { + const env = makeEnv({ + local: { 'notebook-bodies/note-a.body': localFile('a') }, + localCatalog: catalogRaw(['note-a']), + backup: { [`${BACKUP_DIR}/${BACKUP_MANIFEST_NAME}`]: '{{{corrupt' }, + }); + const result = await syncBackup(env); + assert.deepEqual(result, { status: 'ok', copied: 1, removed: 0 }); + assert.equal(manifestIn(env).version, 1); +}); + +test('EDGE: unsafe relative paths are never copied in either direction', async () => { + assert.equal(isSafeRelPath('../escape'), false); + assert.equal(isSafeRelPath('/abs'), false); + assert.equal(isSafeRelPath('a/../../b'), false); + assert.equal(isSafeRelPath('notebook-bodies/ok.body'), true); + + const env = makeEnv({ + backup: { + [`${BACKUP_DIR}/${BACKUP_MANIFEST_NAME}`]: JSON.stringify({ + version: 1, + files: { '../../etc/passwd': { size: 1, mtimeMs: 1 } }, + lastBackupAt: 1, + noteCount: 1, + }), + [`${BACKUP_DIR}/../../etc/passwd`]: 'evil', + }, + }); + const result = await restoreFromBackup(env); + assert.equal(env.localFiles.has('../../etc/passwd'), false); + assert.equal(result.restored, 0); +}); + +test('restore is offered only for an empty library with a non-empty backup', async () => { + const backup = { + [`${BACKUP_DIR}/${BACKUP_CATALOG_NAME}`]: catalogRaw(['note-a']), + [`${BACKUP_DIR}/${BACKUP_MANIFEST_NAME}`]: JSON.stringify({ + version: 1, + files: { 'notebook-bodies/note-a.body': { size: 1, mtimeMs: 1 } }, + lastBackupAt: 123, + noteCount: 1, + }), + [`${BACKUP_DIR}/notebook-bodies/note-a.body`]: 'a', + }; + + const emptyLocal = makeEnv({ backup }); + const offer = await checkRestoreAvailable(emptyLocal); + assert.deepEqual(offer, { noteCount: 1, lastBackupAt: 123 }); + + // EDGE: local notes exist in the catalog -> never offer. + const withCatalog = makeEnv({ backup, localCatalog: catalogRaw(['note-x']) }); + assert.equal(await checkRestoreAvailable(withCatalog), null); + + // EDGE: local body files exist even without a catalog -> never offer. + const withBodies = makeEnv({ + backup, + local: { 'notebook-bodies/note-x.body': localFile('x') }, + }); + assert.equal(await checkRestoreAvailable(withBodies), null); + + // EDGE: empty backup -> nothing to offer. + const emptyBackup = makeEnv({}); + assert.equal(await checkRestoreAvailable(emptyBackup), null); + + // EDGE: backup disabled -> no offer. + const disabled = makeEnv({ backup, enabled: false }); + assert.equal(await checkRestoreAvailable(disabled), null); + + // EDGE: iCloud unavailable -> no offer. + const unavailable = makeEnv({ backup, container: null }); + assert.equal(await checkRestoreAvailable(unavailable), null); +}); + +test('EDGE: restore offer works from file listing when manifest and catalog are corrupt', async () => { + const env = makeEnv({ + backup: { + [`${BACKUP_DIR}/${BACKUP_MANIFEST_NAME}`]: 'corrupt{{', + [`${BACKUP_DIR}/${BACKUP_CATALOG_NAME}`]: 'also-corrupt{{', + [`${BACKUP_DIR}/notebook-bodies/note-a.body`]: 'a', + }, + }); + const offer = await checkRestoreAvailable(env); + assert.deepEqual(offer, { noteCount: 1, lastBackupAt: null }); +}); + +test('restore copies everything, installs the catalog, and round-trips a full backup', async () => { + const source = makeEnv({ + local: { + 'notebook-bodies/note-a.body': localFile('body-a'), + 'pdfs/note-a.pdf': localFile('pdf-a'), + 'images/note-a/img1.png': localFile('img-1'), + }, + localCatalog: catalogRaw(['note-a']), + }); + await syncBackup(source); + + // Same backup contents, fresh empty device (the delete-and-reinstall case). + const fresh = makeEnv({ backup: Object.fromEntries(source.backupFiles) }); + 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'])); +}); + +test('EDGE: restore never overwrites files that already exist locally', async () => { + const env = makeEnv({ + local: { 'notebook-bodies/note-a.body': localFile('local-version') }, + backup: { + [`${BACKUP_DIR}/notebook-bodies/note-a.body`]: 'backup-version', + [`${BACKUP_DIR}/notebook-bodies/note-b.body`]: 'body-b', + }, + }); + const result = await restoreFromBackup(env); + assert.deepEqual(result, { status: 'ok', restored: 1 }); + assert.equal(env.localFiles.get('notebook-bodies/note-a.body').contents, 'local-version'); +}); + +test('EDGE: restore never overwrites a non-empty local catalog', async () => { + const env = makeEnv({ + localCatalog: catalogRaw(['note-local']), + backup: { + [`${BACKUP_DIR}/${BACKUP_CATALOG_NAME}`]: catalogRaw(['note-backup']), + [`${BACKUP_DIR}/notebook-bodies/note-backup.body`]: 'b', + }, + }); + await restoreFromBackup(env); + assert.equal(env.localCatalog, catalogRaw(['note-local'])); +}); + +test('EDGE: evicted (undownloadable) files are counted as failures, rest still restores', async () => { + const env = makeEnv({ + backup: { + [`${BACKUP_DIR}/notebook-bodies/note-a.body`]: 'a', + [`${BACKUP_DIR}/notebook-bodies/note-b.body`]: 'b', + }, + undownloadableRels: new Set(['notebook-bodies/note-b.body']), + }); + const result = await restoreFromBackup(env); + assert.equal(result.status, 'partial'); + assert.equal(result.restored, 1); + assert.equal(result.failed, 1); + assert.equal(env.localFiles.has('notebook-bodies/note-a.body'), true); +}); + +test('EDGE: restore with corrupt backup catalog still restores body files', async () => { + const env = makeEnv({ + backup: { + [`${BACKUP_DIR}/${BACKUP_CATALOG_NAME}`]: 'corrupt{{', + [`${BACKUP_DIR}/notebook-bodies/note-a.body`]: 'a', + }, + }); + const result = await restoreFromBackup(env); + assert.deepEqual(result, { status: 'ok', restored: 1 }); + assert.equal(env.localCatalog, null); +}); + +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 () => { + throw new Error('filesystem exploded'); + }; + const result = await syncBackup(env); + assert.equal(result.status, 'partial'); +}); diff --git a/src/components/library/OpenNotesSheet.tsx b/src/components/library/OpenNotesSheet.tsx index fbf730c..f90c425 100644 --- a/src/components/library/OpenNotesSheet.tsx +++ b/src/components/library/OpenNotesSheet.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { Pressable, ScrollView, StyleSheet, Switch, Text, View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useTheme } from '../../hooks/useTheme'; import { radius, spacing } from '../../theme/spacing'; @@ -13,6 +13,12 @@ interface OpenNotesSheetProps { onJoinCommunity: () => void; onRate: () => void; onViewIntroduction: () => void; + /** Hides the backup row entirely on unsupported platforms. */ + backupSupported: boolean; + /** null while loading; the switch is hidden until known. */ + backupEnabled: boolean | null; + backupSubtitle: string; + onToggleBackup: (enabled: boolean) => void; } export function OpenNotesSheet({ @@ -21,6 +27,10 @@ export function OpenNotesSheet({ onJoinCommunity, onRate, onViewIntroduction, + backupSupported, + backupEnabled, + backupSubtitle, + onToggleBackup, }: OpenNotesSheetProps) { const theme = useTheme(); @@ -82,6 +92,34 @@ export function OpenNotesSheet({ /> + {backupSupported ? ( + + + ) : null + } + /> + + ) : null} + void; + onPress?: () => void; isLast?: boolean; + /** Right-side accessory; defaults to a chevron for pressable rows. */ + trailing?: React.ReactNode; }) { const theme = useTheme(); return ( [ styles.row, !isLast && { borderBottomColor: theme.colors.divider, borderBottomWidth: StyleSheet.hairlineWidth, }, - pressed && { backgroundColor: theme.colors.surfaceMuted }, + pressed && onPress && { backgroundColor: theme.colors.surfaceMuted }, ]} > @@ -151,7 +193,11 @@ function SupportRow({ {subtitle} - + {trailing !== undefined ? ( + trailing + ) : ( + + )} ); } diff --git a/src/hooks/useBackup.ts b/src/hooks/useBackup.ts new file mode 100644 index 0000000..05f3647 --- /dev/null +++ b/src/hooks/useBackup.ts @@ -0,0 +1,145 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Alert, Platform } from 'react-native'; +import { catalogStore } from '../services/catalogEnv'; +import { + checkBackupRestoreAvailable, + isBackupAvailable, + isBackupEnabled, + performBackupRestore, + scheduleBackupSync, + setBackupEnabled, +} from '../services/backupService'; +import { formatRelative } from '../utils/relativeTime'; + +export interface UseBackupResult { + /** False on platforms without a backup implementation (Android, for now). */ + backupSupported: boolean; + backupEnabled: boolean | null; + backupSubtitle: string; + toggleBackup: (enabled: boolean) => void; +} + +const BACKUP_SUPPORTED = Platform.OS === 'ios'; + +/** + * Backup state for the library screen: the enable switch, a status line, and + * a one-time restore offer when the library is empty but a backup exists + * (fresh install after the app was deleted). + */ +export function useBackup(refreshLibrary: () => Promise): UseBackupResult { + const [enabled, setEnabled] = useState(null); + const [available, setAvailable] = useState(null); + const restorePromptShownRef = useRef(false); + + useEffect(() => { + if (!BACKUP_SUPPORTED) return; + let cancelled = false; + void (async () => { + const [isEnabled, isAvailable] = await Promise.all([ + isBackupEnabled(), + isBackupAvailable(), + ]); + if (cancelled) return; + setEnabled(isEnabled); + setAvailable(isAvailable); + })(); + return () => { + cancelled = true; + }; + }, []); + + const offerRestore = useCallback(async () => { + if (restorePromptShownRef.current) return; + const availability = await checkBackupRestoreAvailable(); + if (!availability || restorePromptShownRef.current) { + // 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 + // deliberately NOT scheduled while a restore offer is on screen - the + // engine's empty-local guard also protects the backup, but the order + // here makes the race impossible rather than merely survivable. + scheduleBackupSync(); + return; + } + restorePromptShownRef.current = true; + + const when = availability.lastBackupAt + ? ` (backed up ${formatRelative(new Date(availability.lastBackupAt).toISOString())})` + : ''; + const noun = availability.noteCount === 1 ? 'note' : 'notes'; + Alert.alert( + 'Restore your notes?', + `An iCloud backup with ${availability.noteCount} ${noun}${when} was found for this device's account.`, + [ + { + text: 'Not now', + style: 'cancel', + onPress: () => scheduleBackupSync(), + }, + { + text: 'Restore', + onPress: () => { + void (async () => { + const result = await performBackupRestore(); + await catalogStore.invalidate(); + await refreshLibrary(); + scheduleBackupSync(); + if (result.status === 'ok') { + Alert.alert('Restore complete', `${result.restored} files restored.`); + } else { + Alert.alert( + 'Restore finished with problems', + result.status === 'partial' + ? `${result.restored} files restored, ${result.failed} could not be read from iCloud. Try again later for the rest.` + : 'iCloud is not reachable right now. Try again later.', + ); + } + })(); + }, + }, + ], + ); + }, [refreshLibrary]); + + useEffect(() => { + if (!BACKUP_SUPPORTED) return; + void offerRestore(); + }, [offerRestore]); + + const toggleBackup = useCallback((next: boolean) => { + if (next) { + setEnabled(true); + void setBackupEnabled(true); + return; + } + Alert.alert( + 'Turn off iCloud backup?', + 'Your notes will exist only on this device. Deleting the app will permanently delete them.', + [ + { text: 'Keep backup on', style: 'cancel' }, + { + text: 'Turn off', + style: 'destructive', + onPress: () => { + setEnabled(false); + void setBackupEnabled(false); + }, + }, + ], + ); + }, []); + + const backupSubtitle = + enabled === false + ? 'Off — notes exist only on this device' + : available === false + ? 'iCloud unavailable — sign in to iCloud to protect your notes' + : 'Automatic — your notes survive app deletion'; + + return { + backupSupported: BACKUP_SUPPORTED, + backupEnabled: enabled, + backupSubtitle, + toggleBackup, + }; +} diff --git a/src/services/backupEngine.ts b/src/services/backupEngine.ts new file mode 100644 index 0000000..59b3725 --- /dev/null +++ b/src/services/backupEngine.ts @@ -0,0 +1,384 @@ +// Pure backup/restore logic with an injected environment, so every failure +// mode is testable under plain `node --test` (like catalogStore). The real +// wiring lives in backupEnv.ts; the iCloud specifics live in the native +// ICloudBackupModule. +// +// Model: the device is the source of truth; the iCloud Drive container holds +// a mirror of the data files plus a manifest describing what was mirrored. +// 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'; + +export const BACKUP_SUBDIR = 'OpenNotesBackup'; +export const BACKUP_MANIFEST_NAME = 'manifest.json'; +export const BACKUP_CATALOG_NAME = 'notes-catalog.json'; +/** Documents-relative directories mirrored to the backup. */ +export const DATA_DIRS = ['notebook-bodies', 'pdfs', 'images'] as const; +const BODIES_PREFIX = `${DATA_DIRS[0]}/`; +const ENSURE_DOWNLOAD_TIMEOUT_MS = 30000; + +export interface BackupFileInfo { + /** Path relative to the app Documents directory, e.g. "notebook-bodies/x.body". */ + rel: string; + size: number; + mtimeMs: number; +} + +export interface BackupManifest { + version: 1; + files: Record; + lastBackupAt: number; + noteCount: number; +} + +export interface BackupEnv { + /** Absolute path of the iCloud container Documents dir, or null when iCloud is unavailable. */ + getContainerDir(): Promise; + isEnabled(): Promise; + readLocalCatalog(): Promise; + /** Writes the local catalog file atomically. */ + writeLocalCatalog(raw: string): Promise; + /** Every local data file (bodies, pdfs, images) with size and mtime. */ + listLocalDataFiles(): Promise; + localFileExists(rel: string): Promise; + /** Copies one local data file into the backup dir, creating directories. */ + copyLocalToBackup(rel: string, backupDir: string): Promise; + /** Copies one backup file into the local Documents dir, creating directories. */ + copyBackupToLocal(backupDir: string, rel: string): Promise; + /** Returns file contents, null when missing or unreadable. */ + readBackupFile(absPath: string): Promise; + writeBackupFileAtomic(absPath: string, contents: string): Promise; + deleteBackupFile(absPath: string): Promise; + /** Recursively lists data files under the backup dir, as Documents-relative paths. */ + listBackupDataFiles(backupDir: string): Promise; + /** Makes an iCloud-evicted file locally readable. True when readable. */ + ensureDownloaded(absPath: string, timeoutMs: number): Promise; + now(): number; + warn(message: string, error?: unknown): void; +} + +export type BackupSyncResult = + | { status: 'disabled' } + | { status: 'unavailable' } + /** + * The local library is empty but the backup holds notes - the fresh-install + * state where the restore question is still open. Pushing would overwrite + * the backup catalog with an empty one, so the sync refuses to touch it. + */ + | { status: 'skipped-empty-local' } + | { status: 'ok'; copied: number; removed: number } + | { status: 'partial'; copied: number; removed: number; failed: number }; + +/** Maps a mirrored data-file path back to the note id it belongs to. */ +export function noteIdForRel(rel: string): string | null { + const [dir, ...rest] = rel.split('/'); + if (rest.length === 0) return null; + try { + if (dir === DATA_DIRS[0] && rest.length === 1 && rest[0].endsWith('.body')) { + return decodeURIComponent(rest[0].slice(0, -'.body'.length)) || null; + } + if (dir === DATA_DIRS[1] && rest.length === 1 && rest[0].endsWith('.pdf')) { + return decodeURIComponent(rest[0].slice(0, -'.pdf'.length)) || null; + } + if (dir === DATA_DIRS[2]) { + return decodeURIComponent(rest[0]) || null; + } + } catch { + return null; + } + return null; +} + +export interface RestoreAvailability { + noteCount: number; + lastBackupAt: number | null; +} + +export type RestoreResult = + | { status: 'unavailable' } + | { status: 'ok'; restored: number } + | { status: 'partial'; restored: number; failed: number }; + +/** + * Rejects any path that could escape its base directory. Backup contents are + * user-account data but treated as untrusted input for path construction. + */ +export function isSafeRelPath(rel: string): boolean { + if (!rel || rel.startsWith('/') || rel.includes('\\') || rel.includes('\0')) return false; + const segments = rel.split('/'); + return segments.every((s) => s.length > 0 && s !== '.' && s !== '..'); +} + +function backupDirPath(containerDir: string): string { + return `${containerDir.replace(/\/$/, '')}/${BACKUP_SUBDIR}`; +} + +function parseManifest(raw: string | null, warn: BackupEnv['warn']): BackupManifest | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as Partial; + if (typeof parsed !== 'object' || parsed === null) return null; + const files: BackupManifest['files'] = {}; + if (typeof parsed.files === 'object' && parsed.files !== null) { + for (const [rel, entry] of Object.entries(parsed.files)) { + if ( + isSafeRelPath(rel) && + typeof entry === 'object' && + entry !== null && + typeof entry.size === 'number' && + typeof entry.mtimeMs === 'number' + ) { + files[rel] = { size: entry.size, mtimeMs: entry.mtimeMs }; + } + } + } + return { + version: 1, + files, + lastBackupAt: typeof parsed.lastBackupAt === 'number' ? parsed.lastBackupAt : 0, + noteCount: typeof parsed.noteCount === 'number' ? parsed.noteCount : 0, + }; + } catch { + warn('[backupEngine] backup manifest corrupt; performing full re-mirror'); + return null; + } +} + +function localNoteCount(catalogRaw: string | null): number | null { + if (!catalogRaw) return null; + const catalog = parseCatalog(catalogRaw); + return catalog ? catalog.notes.length : null; +} + +/** + * Pushes local state into the backup mirror. Copies new/changed files, + * removes files deleted locally, then writes the catalog and manifest last - + * so a crash mid-sync leaves a manifest that still describes mirrored files + * accurately (unlisted copies are re-copied harmlessly next sync). + * + * Never throws; local saving must never be affected by backup failures. + */ +export async function syncBackup(env: BackupEnv): Promise { + try { + if (!(await env.isEnabled())) return { status: 'disabled' }; + const containerDir = await env.getContainerDir(); + if (!containerDir) return { status: 'unavailable' }; + const backupDir = backupDirPath(containerDir); + + const manifestRaw = await env.readBackupFile(`${backupDir}/${BACKUP_MANIFEST_NAME}`); + const previous = parseManifest(manifestRaw, env.warn); + const previousFiles = previous?.files ?? {}; + + const catalogRaw = await env.readLocalCatalog(); + const localCatalog = catalogRaw ? parseCatalog(catalogRaw) : null; + const localFiles = await env.listLocalDataFiles(); + const localByRel = new Map(localFiles.map((f) => [f.rel, f])); + + // Fresh-install guard: an empty local library must never overwrite a + // backup that still holds notes. The restore flow resolves this state. + const localIsEmpty = + (localCatalog?.notes.length ?? 0) === 0 && + !localFiles.some((f) => f.rel.startsWith(BODIES_PREFIX)); + const backupHasNotes = + (previous?.noteCount ?? 0) > 0 || + Object.keys(previousFiles).some((rel) => rel.startsWith(BODIES_PREFIX)); + if (localIsEmpty && backupHasNotes) { + env.warn('[backupEngine] local library empty but backup has notes; sync skipped'); + return { status: 'skipped-empty-local' }; + } + + const tombstones = localCatalog?.deletedNoteIds ?? {}; + + let copied = 0; + let removed = 0; + let failed = 0; + const nextFiles: BackupManifest['files'] = {}; + + for (const file of localFiles) { + if (!isSafeRelPath(file.rel)) { + env.warn(`[backupEngine] skipping unsafe local path ${file.rel}`); + continue; + } + const prev = previousFiles[file.rel]; + if (prev && prev.size === file.size && prev.mtimeMs === file.mtimeMs) { + nextFiles[file.rel] = prev; + continue; + } + const ok = await env.copyLocalToBackup(file.rel, backupDir); + if (ok) { + nextFiles[file.rel] = { size: file.size, mtimeMs: file.mtimeMs }; + copied += 1; + } else { + failed += 1; + env.warn(`[backupEngine] copy to backup failed for ${file.rel}`); + // Keep the stale entry, if any, so the old mirrored version is not + // deleted; the copy retries next sync because sizes/mtimes differ. + if (prev) nextFiles[file.rel] = prev; + } + } + + for (const rel of Object.keys(previousFiles)) { + if (localByRel.has(rel)) continue; + // Deletions propagate ONLY for explicitly tombstoned notes. A file that + // is merely missing locally (fresh install, partial restore, older + // device) stays in the backup untouched. + const noteId = noteIdForRel(rel); + if (!noteId || !tombstones[noteId]) { + nextFiles[rel] = previousFiles[rel]; + continue; + } + try { + await env.deleteBackupFile(`${backupDir}/${rel}`); + removed += 1; + } catch (error) { + failed += 1; + env.warn(`[backupEngine] delete from backup failed for ${rel}`, error); + nextFiles[rel] = previousFiles[rel]; + } + } + + if (catalogRaw) { + const ok = await env.writeBackupFileAtomic( + `${backupDir}/${BACKUP_CATALOG_NAME}`, + catalogRaw, + ); + if (!ok) { + failed += 1; + env.warn('[backupEngine] catalog write to backup failed'); + } + } + + const manifest: BackupManifest = { + version: 1, + files: nextFiles, + lastBackupAt: env.now(), + noteCount: + localNoteCount(catalogRaw) ?? + localFiles.filter((f) => f.rel.startsWith(BODIES_PREFIX)).length, + }; + const manifestOk = await env.writeBackupFileAtomic( + `${backupDir}/${BACKUP_MANIFEST_NAME}`, + JSON.stringify(manifest), + ); + if (!manifestOk) { + failed += 1; + env.warn('[backupEngine] manifest write to backup failed'); + } + + if (failed > 0) return { status: 'partial', copied, removed, failed }; + return { status: 'ok', copied, removed }; + } catch (error) { + env.warn('[backupEngine] sync failed unexpectedly', error); + return { status: 'partial', copied: 0, removed: 0, failed: 1 }; + } +} + +/** + * A restore is offered only when the local library is empty (no catalog notes + * AND no body files) and the backup holds at least one note. Restoring must + * never overwrite existing local data. + */ +export async function checkRestoreAvailable(env: BackupEnv): Promise { + try { + if (!(await env.isEnabled())) return null; + + const localCount = localNoteCount(await env.readLocalCatalog()); + if (localCount !== null && localCount > 0) return null; + const localFiles = await env.listLocalDataFiles(); + if (localFiles.some((f) => f.rel.startsWith(BODIES_PREFIX))) return null; + + const containerDir = await env.getContainerDir(); + if (!containerDir) return null; + const backupDir = backupDirPath(containerDir); + + const manifest = parseManifest( + await env.readBackupFile(`${backupDir}/${BACKUP_MANIFEST_NAME}`), + env.warn, + ); + const backupCatalog = parseCatalog( + (await env.readBackupFile(`${backupDir}/${BACKUP_CATALOG_NAME}`)) ?? '', + ); + + const bodyCount = + backupCatalog?.notes.length ?? + manifest?.noteCount ?? + (await env.listBackupDataFiles(backupDir)).filter((rel) => + rel.startsWith(BODIES_PREFIX), + ).length; + if (bodyCount === 0) return null; + + return { + noteCount: bodyCount, + lastBackupAt: manifest?.lastBackupAt ?? null, + }; + } catch (error) { + env.warn('[backupEngine] restore availability check failed', error); + return null; + } +} + +/** + * 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. + */ +export async function restoreFromBackup(env: BackupEnv): Promise { + try { + const containerDir = await env.getContainerDir(); + if (!containerDir) return { status: 'unavailable' }; + const backupDir = backupDirPath(containerDir); + + const manifest = parseManifest( + await env.readBackupFile(`${backupDir}/${BACKUP_MANIFEST_NAME}`), + env.warn, + ); + const listed = await env.listBackupDataFiles(backupDir); + const rels = new Set([...Object.keys(manifest?.files ?? {}), ...listed]); + + let restored = 0; + let failed = 0; + for (const rel of rels) { + // Restores may only land inside the known data directories - never in + // the Documents root (where the catalog and dev override live). + if (!isSafeRelPath(rel) || noteIdForRel(rel) === null) { + env.warn(`[backupEngine] skipping unsafe backup path ${rel}`); + continue; + } + if (await env.localFileExists(rel)) continue; + const abs = `${backupDir}/${rel}`; + const downloaded = await env.ensureDownloaded(abs, ENSURE_DOWNLOAD_TIMEOUT_MS); + if (!downloaded) { + failed += 1; + env.warn(`[backupEngine] backup file not downloadable: ${rel}`); + continue; + } + const ok = await env.copyBackupToLocal(backupDir, rel); + if (ok) restored += 1; + else { + failed += 1; + env.warn(`[backupEngine] restore copy failed for ${rel}`); + } + } + + 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'); + } + } + } + + if (failed > 0) return { status: 'partial', restored, failed }; + return { status: 'ok', restored }; + } catch (error) { + env.warn('[backupEngine] restore failed unexpectedly', error); + return { status: 'partial', restored: 0, failed: 1 }; + } +} diff --git a/src/services/backupService.ts b/src/services/backupService.ts new file mode 100644 index 0000000..bc9dae8 --- /dev/null +++ b/src/services/backupService.ts @@ -0,0 +1,314 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as FileSystem from 'expo-file-system/legacy'; +import { AppState, NativeModules } from 'react-native'; +import { writeStringAtomic } from './atomicFile'; +import { + DATA_DIRS, + checkRestoreAvailable, + restoreFromBackup, + syncBackup, + type BackupEnv, + type BackupFileInfo, + type BackupSyncResult, + type RestoreAvailability, + type RestoreResult, +} from './backupEngine'; +import { CATALOG_FILENAME } from './catalogStore'; + +const ENABLED_KEY = '@opennotes:backup:enabled'; +const SYNC_DEBOUNCE_MS = 8000; +const DEV_CONTAINER_OVERRIDE_FILE = 'dev-backup-container-path.txt'; + +// All backup-side file I/O goes through the native module: expo-file-system +// refuses paths outside the app sandbox scopes, and the iCloud ubiquity +// container is outside them. +type ICloudBackupModuleType = { + getContainerPath?: () => Promise; + ensureDownloaded?: (path: string, timeoutMs: number) => Promise; + copyItem?: (from: string, to: string) => Promise; + writeFileAtomic?: (path: string, contents: string) => Promise; + readFileAsString?: (path: string) => Promise; + deleteItem?: (path: string) => Promise; + listFilesRecursive?: ( + dir: string, + ) => Promise>; +}; + +const ICloudBackupModule = NativeModules.ICloudBackupModule as + | ICloudBackupModuleType + | undefined; + +function documentsDir(): string { + return FileSystem.documentDirectory ?? ''; +} + +function localAbs(rel: string): string { + return `${documentsDir()}${rel}`; +} + +async function listDataFilesUnder( + relDir: string, + out: BackupFileInfo[], +): Promise { + const absDir = localAbs(relDir); + const dirInfo = await FileSystem.getInfoAsync(absDir); + if (!dirInfo.exists) return; + const names = await FileSystem.readDirectoryAsync(absDir); + for (const name of names) { + if (name.endsWith('.tmp')) continue; + const rel = `${relDir}/${name}`; + const info = await FileSystem.getInfoAsync(localAbs(rel)); + if (!info.exists) continue; + if (info.isDirectory) { + await listDataFilesUnder(rel, out); + } else { + out.push({ + rel, + size: typeof info.size === 'number' ? info.size : 0, + mtimeMs: + typeof info.modificationTime === 'number' + ? Math.round(info.modificationTime * 1000) + : 0, + }); + } + } +} + +/** + * iCloud shows not-yet-downloaded files as "..icloud" placeholders; + * surface them under their real name so restore triggers the download. + */ +function normalizeICloudPlaceholder(rel: string): string { + const slash = rel.lastIndexOf('/'); + const dir = slash >= 0 ? rel.slice(0, slash + 1) : ''; + const name = slash >= 0 ? rel.slice(slash + 1) : rel; + if (name.startsWith('.') && name.endsWith('.icloud')) { + return `${dir}${name.slice(1, -'.icloud'.length)}`; + } + return rel; +} + +async function devContainerOverride(): Promise { + if (!__DEV__) return null; + try { + const path = localAbs(DEV_CONTAINER_OVERRIDE_FILE); + const info = await FileSystem.getInfoAsync(path); + if (!info.exists) return null; + const contents = (await FileSystem.readAsStringAsync(path)).trim(); + return contents || null; + } catch { + return null; + } +} + +const env: BackupEnv = { + async getContainerDir(): Promise { + const override = await devContainerOverride(); + if (override) return override; + if (!ICloudBackupModule?.getContainerPath) return null; + try { + return await ICloudBackupModule.getContainerPath(); + } catch (error) { + env.warn('[backupService] container lookup failed', error); + return null; + } + }, + + async isEnabled(): Promise { + try { + return (await AsyncStorage.getItem(ENABLED_KEY)) !== 'false'; + } catch { + return true; + } + }, + + async readLocalCatalog(): Promise { + try { + const path = localAbs(CATALOG_FILENAME); + const info = await FileSystem.getInfoAsync(path); + if (!info.exists) return null; + return await FileSystem.readAsStringAsync(path); + } catch { + return null; + } + }, + + writeLocalCatalog(raw: string): Promise { + return writeStringAtomic(localAbs(CATALOG_FILENAME), raw).catch(() => false); + }, + + async listLocalDataFiles(): Promise { + const out: BackupFileInfo[] = []; + for (const dir of DATA_DIRS) { + // The native walk returns every file with its stat in ONE bridge call; + // the JS walk (one getInfoAsync per file) is the module-less fallback. + if (ICloudBackupModule?.listFilesRecursive) { + const entries = await ICloudBackupModule.listFilesRecursive(localAbs(dir)); + for (const entry of entries) { + if (entry.rel.endsWith('.tmp')) continue; + out.push({ rel: `${dir}/${entry.rel}`, size: entry.size, mtimeMs: entry.mtimeMs }); + } + } else { + await listDataFilesUnder(dir, out); + } + } + return out; + }, + + async localFileExists(rel: string): Promise { + const info = await FileSystem.getInfoAsync(localAbs(rel)); + return info.exists; + }, + + async copyLocalToBackup(rel: string, backupDir: string): Promise { + if (!ICloudBackupModule?.copyItem) return false; + try { + await ICloudBackupModule.copyItem(localAbs(rel), `${backupDir}/${rel}`); + return true; + } catch (error) { + env.warn(`[backupService] copy to backup failed for ${rel}`, error); + return false; + } + }, + + async copyBackupToLocal(backupDir: string, rel: string): Promise { + if (!ICloudBackupModule?.copyItem) return false; + try { + await ICloudBackupModule.copyItem(`${backupDir}/${rel}`, localAbs(rel)); + return true; + } catch (error) { + env.warn(`[backupService] restore copy failed for ${rel}`, error); + return false; + } + }, + + async readBackupFile(absPath: string): Promise { + if (!ICloudBackupModule?.readFileAsString) return null; + try { + return await ICloudBackupModule.readFileAsString(absPath); + } catch (error) { + env.warn(`[backupService] backup read failed for ${absPath}`, error); + return null; + } + }, + + async writeBackupFileAtomic(absPath: string, contents: string): Promise { + if (!ICloudBackupModule?.writeFileAtomic) return false; + try { + return await ICloudBackupModule.writeFileAtomic(absPath, contents); + } catch (error) { + env.warn(`[backupService] backup write failed for ${absPath}`, error); + return false; + } + }, + + async deleteBackupFile(absPath: string): Promise { + if (!ICloudBackupModule?.deleteItem) return; + await ICloudBackupModule.deleteItem(absPath); + }, + + 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}`)); + } + } + return out; + }, + + async ensureDownloaded(absPath: string, timeoutMs: number): Promise { + if (!ICloudBackupModule?.ensureDownloaded) return false; + try { + return await ICloudBackupModule.ensureDownloaded(absPath, timeoutMs); + } catch (error) { + env.warn(`[backupService] download failed for ${absPath}`, error); + return false; + } + }, + + now(): number { + return Date.now(); + }, + + warn(message: string, error?: unknown): void { + if (__DEV__) console.warn(message, error ?? ''); + }, +}; + +let syncTimer: ReturnType | null = null; +let syncInFlight: Promise | null = null; +let syncQueued = false; + +function startSync(): Promise { + if (syncInFlight) { + // A sync is running against a snapshot that may already be stale; run one + // more full pass when it finishes. + syncQueued = true; + return syncInFlight; + } + const promise = syncBackup(env).finally(() => { + syncInFlight = null; + if (syncQueued) { + syncQueued = false; + void startSync(); + } + }); + syncInFlight = promise; + return promise; +} + +/** Debounced backup push; called after every successful catalog persist. */ +export function scheduleBackupSync(): void { + if (syncTimer) clearTimeout(syncTimer); + syncTimer = setTimeout(() => { + syncTimer = null; + void startSync(); + }, SYNC_DEBOUNCE_MS); +} + +/** Runs any pending or new sync immediately (app background, manual). */ +export function flushBackupSync(): Promise { + if (syncTimer) { + clearTimeout(syncTimer); + syncTimer = null; + } + return startSync(); +} + +export async function isBackupEnabled(): Promise { + return env.isEnabled(); +} + +/** True when an iCloud container (or dev override) is reachable right now. */ +export async function isBackupAvailable(): Promise { + return (await env.getContainerDir()) !== null; +} + +export async function setBackupEnabled(enabled: boolean): Promise { + await AsyncStorage.setItem(ENABLED_KEY, enabled ? 'true' : 'false'); + if (enabled) { + // Catch up immediately so re-enabling mirrors everything without waiting + // for the next edit. + void flushBackupSync(); + } +} + +export function checkBackupRestoreAvailable(): Promise { + return checkRestoreAvailable(env); +} + +export function performBackupRestore(): Promise { + return restoreFromBackup(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) => { + if ((state === 'background' || state === 'inactive') && syncTimer) { + void flushBackupSync(); + } +}); diff --git a/src/services/catalogEnv.ts b/src/services/catalogEnv.ts index d076311..d5def07 100644 --- a/src/services/catalogEnv.ts +++ b/src/services/catalogEnv.ts @@ -1,12 +1,16 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import * as FileSystem from 'expo-file-system/legacy'; import { writeStringAtomic } from './atomicFile'; -import { createCatalogStore, type CatalogEnv, type CatalogStore } from './catalogStore'; +import { scheduleBackupSync } from './backupService'; +import { + CATALOG_FILENAME, + createCatalogStore, + type CatalogEnv, + type CatalogStore, +} from './catalogStore'; import { bodyModifiedAt, listBodyIds, readBody } from './noteBodyStorage'; import { pdfUriForNote } from './pdfStorage'; -const CATALOG_FILENAME = 'notes-catalog.json'; - function catalogPath(): string { return `${FileSystem.documentDirectory ?? ''}${CATALOG_FILENAME}`; } @@ -21,8 +25,14 @@ const env: CatalogEnv = { return FileSystem.readAsStringAsync(path); }, - writeCatalogFile(json: string): Promise { - return writeStringAtomic(catalogPath(), json); + async writeCatalogFile(json: string): Promise { + const ok = await writeStringAtomic(catalogPath(), json); + if (ok) { + // The catalog persists on every data mutation (note bodies update their + // metadata too), so this is the single choke point for backup pushes. + scheduleBackupSync(); + } + return ok; }, async preserveCorruptCatalogFile(): Promise { diff --git a/src/services/catalogStore.ts b/src/services/catalogStore.ts index b1ee925..3c252ad 100644 --- a/src/services/catalogStore.ts +++ b/src/services/catalogStore.ts @@ -32,6 +32,29 @@ export interface Catalog { version: 1; notes: NoteMetadata[]; folders: FolderMetadata[]; + /** + * Tombstones: note ids the user deliberately deleted, with deletion time. + * Backup sync propagates deletions ONLY for tombstoned ids - a file merely + * missing locally (fresh install, partial restore) must never delete its + * backup copy. Pruned after TOMBSTONE_TTL_MS. + */ + deletedNoteIds: Record; +} + +export const TOMBSTONE_TTL_MS = 90 * 24 * 60 * 60 * 1000; + +/** Removes tombstones old enough that every backup has long since synced. */ +export function pruneTombstones( + tombstones: Record, + nowIso: string, +): Record { + const cutoff = Date.parse(nowIso) - TOMBSTONE_TTL_MS; + const out: Record = {}; + for (const [id, deletedAt] of Object.entries(tombstones)) { + const t = Date.parse(deletedAt); + if (!Number.isNaN(t) && t >= cutoff) out[id] = deletedAt; + } + return out; } export interface KeyValueStore { @@ -63,6 +86,9 @@ export interface CatalogEnv { warn(message: string, error?: unknown): void; } +/** Filename of the durable catalog inside the app Documents directory. */ +export const CATALOG_FILENAME = 'notes-catalog.json'; + export const NOTES_INDEX_KEY = '@opennotes:notes:index'; export const NOTE_KEY_PREFIX = '@opennotes:note:'; export const FOLDERS_INDEX_KEY = '@opennotes:folders:index'; @@ -141,7 +167,15 @@ export function parseCatalog(raw: string): Catalog | null { folders.push(folder); } } - return { version: 1, notes, folders }; + const deletedNoteIds: Record = {}; + if (typeof v.deletedNoteIds === 'object' && v.deletedNoteIds !== null) { + for (const [id, deletedAt] of Object.entries(v.deletedNoteIds as Record)) { + if (isNonEmptyString(id) && isNonEmptyString(deletedAt)) { + deletedNoteIds[id] = deletedAt; + } + } + } + return { version: 1, notes, folders, deletedNoteIds }; } function parseIndex(raw: string | null): string[] { @@ -273,6 +307,17 @@ async function reconcileCatalog( const recovered = await Promise.all(orphanIds.map((id) => recoverNote(env, id))); if (recovered.length > 0) changed = true; + // A recovered note whose id was tombstoned gets its tombstone dropped: + // the body file's presence wins over a recorded deletion (bias toward + // resurrecting data, never toward losing it). + let deletedNoteIds = catalog.deletedNoteIds; + const resurrected = recovered.filter((n) => deletedNoteIds[n.id]); + if (resurrected.length > 0) { + deletedNoteIds = { ...deletedNoteIds }; + for (const note of resurrected) delete deletedNoteIds[note.id]; + changed = true; + } + const folderIds = new Set(catalog.folders.map((f) => f.id)); const notes = [...catalog.notes, ...recovered].map((note) => { if (note.folderId !== null && !folderIds.has(note.folderId)) { @@ -283,7 +328,9 @@ async function reconcileCatalog( }); return { - catalog: changed ? { version: 1, notes, folders: catalog.folders } : catalog, + catalog: changed + ? { version: 1, notes, folders: catalog.folders, deletedNoteIds } + : catalog, changed, }; } @@ -351,6 +398,11 @@ export interface CatalogStore { * change (nothing is persisted). Mutators must not modify the input catalog. */ mutate(fn: (catalog: Catalog) => Catalog | null): Promise; + /** + * Drops the in-memory cache so the next read reloads (and reconciles) from + * disk. Used after a restore writes files behind the store's back. + */ + invalidate(): Promise; } export function createCatalogStore(env: CatalogEnv): CatalogStore { @@ -407,7 +459,7 @@ export function createCatalogStore(env: CatalogEnv): CatalogStore { env.warn, ), ]); - catalog = { version: 1, notes, folders }; + catalog = { version: 1, notes, folders, deletedNoteIds: {} }; } const reconciled = await reconcileCatalog(env, catalog); @@ -476,6 +528,11 @@ export function createCatalogStore(env: CatalogEnv): CatalogStore { getCatalog(): Promise { return queue.enqueue(loadLocked); }, + invalidate(): Promise { + return queue.enqueue(async () => { + cached = null; + }); + }, mutate(fn: (catalog: Catalog) => Catalog | null): Promise { return queue.enqueue(async () => { const current = await loadLocked(); diff --git a/src/services/notesRepo.ts b/src/services/notesRepo.ts index 0a24af9..ed9ac8d 100644 --- a/src/services/notesRepo.ts +++ b/src/services/notesRepo.ts @@ -2,6 +2,7 @@ import type { SerializedNotebookData } from '@mathnotes/mobile-ink'; import type { BackgroundType, NoteMetadata } from '../types/note'; import { noteId as makeNoteId } from '../utils/id'; import { catalogStore } from './catalogEnv'; +import { pruneTombstones } from './catalogStore'; import { deleteBody, readBody, writeBody, type BodyReadResult } from './noteBodyStorage'; import { deletePdfForNote } from './pdfStorage'; import { deleteImagesForNote } from './imageInsertStorage'; @@ -113,22 +114,32 @@ async function deleteNoteFiles(id: string): Promise { export async function deleteNote(id: string): Promise { // Remove the catalog entry first so the note disappears from the library // even if a file delete fails; the body delete prevents the recovery scan - // from resurrecting it. + // from resurrecting it. The tombstone is what allows backup sync to delete + // the mirrored copy - files merely missing locally are never propagated. + const now = new Date().toISOString(); await catalogStore.mutate((catalog) => ({ ...catalog, notes: catalog.notes.filter((n) => n.id !== id), + deletedNoteIds: { + ...pruneTombstones(catalog.deletedNoteIds, now), + [id]: now, + }, })); await deleteNoteFiles(id); } export async function deleteAllNotesInFolder(folderId: string): Promise { let targets: NoteMetadata[] = []; + const now = new Date().toISOString(); await catalogStore.mutate((catalog) => { targets = catalog.notes.filter((n) => n.folderId === folderId); if (targets.length === 0) return null; + const deletedNoteIds = pruneTombstones(catalog.deletedNoteIds, now); + for (const note of targets) deletedNoteIds[note.id] = now; return { ...catalog, notes: catalog.notes.filter((n) => n.folderId !== folderId), + deletedNoteIds, }; }); await Promise.all(targets.map((n) => deleteNoteFiles(n.id)));