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)));