Skip to content

Commit 5b20809

Browse files
authored
feat(backup): automatic iCloud Drive backup with restore after app deletion (#15)
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
1 parent cb4babb commit 5b20809

15 files changed

Lines changed: 1751 additions & 16 deletions

File tree

app.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,16 @@
1818
"icon": "./assets/icon.png",
1919
"buildNumber": "9",
2020
"supportsTablet": true,
21+
"usesIcloudStorage": true,
22+
"entitlements": {
23+
"com.apple.developer.icloud-container-identifiers": [
24+
"iCloud.com.builderpro.opennotes"
25+
],
26+
"com.apple.developer.icloud-services": ["CloudDocuments"],
27+
"com.apple.developer.ubiquity-container-identifiers": [
28+
"iCloud.com.builderpro.opennotes"
29+
]
30+
},
2131
"infoPlist": {
2232
"ITSAppUsesNonExemptEncryption": false,
2333
"NSPhotoLibraryUsageDescription": "OpenNotes needs access to your photo library so you can insert images into your notes.",

app/index.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { CommunityInviteSheet } from '../src/components/library/CommunityInviteS
2323
import { OpenNotesSheet } from '../src/components/library/OpenNotesSheet';
2424
import { LibrarySection } from '../src/components/library/LibrarySection';
2525
import { useOnboarding } from '../src/hooks/useOnboarding';
26+
import { useBackup } from '../src/hooks/useBackup';
2627
import { useLibrarySupport } from '../src/hooks/useLibrarySupport';
2728
import { useTheme } from '../src/hooks/useTheme';
2829
import { spacing } from '../src/theme/spacing';
@@ -89,6 +90,8 @@ export default function LibraryScreen() {
8990
() => setAction({ kind: 'community' }),
9091
[],
9192
);
93+
const { backupSupported, backupEnabled, backupSubtitle, toggleBackup } =
94+
useBackup(refresh);
9295
const { dismissCommunity, joinCommunity, rateOpenNotes } = useLibrarySupport({
9396
canShowAutomaticPrompt:
9497
onboarding.ready && !onboarding.visible && action === null,
@@ -312,6 +315,10 @@ export default function LibraryScreen() {
312315
setAction(null);
313316
onboarding.show();
314317
}}
318+
backupSupported={backupSupported}
319+
backupEnabled={backupEnabled}
320+
backupSubtitle={backupSubtitle}
321+
onToggleBackup={toggleBackup}
315322
/>
316323

317324
<CommunityInviteSheet

ios/OpenNotes.xcodeproj/project.pbxproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
4788858903E15B3CAEB129E6 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B82DFB67047998EABD93C92 /* ExpoModulesProvider.swift */; };
1414
7D3A80162E1A4CC89B1B09C1 /* PDFUtilsModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D3A80142E1A4CC89B1B09C1 /* PDFUtilsModule.swift */; };
1515
7D3A80172E1A4CC89B1B09C1 /* PDFUtilsModule.m in Sources */ = {isa = PBXBuildFile; fileRef = 7D3A80152E1A4CC89B1B09C1 /* PDFUtilsModule.m */; };
16+
7D3A801A2E1A4CC89B1B09C1 /* ICloudBackupModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D3A80182E1A4CC89B1B09C1 /* ICloudBackupModule.swift */; };
17+
7D3A801B2E1A4CC89B1B09C1 /* ICloudBackupModule.m in Sources */ = {isa = PBXBuildFile; fileRef = 7D3A80192E1A4CC89B1B09C1 /* ICloudBackupModule.m */; };
1618
9C2FD7C620026BB779139CCD /* libPods-OpenNotes.a in Frameworks */ = {isa = PBXBuildFile; fileRef = A7A85B485F4321855B927960 /* libPods-OpenNotes.a */; };
1719
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
1820
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
@@ -25,6 +27,8 @@
2527
575BE98DD16F76BFF21A39E6 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = OpenNotes/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
2628
7D3A80142E1A4CC89B1B09C1 /* PDFUtilsModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PDFUtilsModule.swift; path = OpenNotes/PDFUtilsModule.swift; sourceTree = "<group>"; };
2729
7D3A80152E1A4CC89B1B09C1 /* PDFUtilsModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = PDFUtilsModule.m; path = OpenNotes/PDFUtilsModule.m; sourceTree = "<group>"; };
30+
7D3A80182E1A4CC89B1B09C1 /* ICloudBackupModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ICloudBackupModule.swift; path = OpenNotes/ICloudBackupModule.swift; sourceTree = "<group>"; };
31+
7D3A80192E1A4CC89B1B09C1 /* ICloudBackupModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = ICloudBackupModule.m; path = OpenNotes/ICloudBackupModule.m; sourceTree = "<group>"; };
2832
9B82DFB67047998EABD93C92 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-OpenNotes/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
2933
A7A85B485F4321855B927960 /* libPods-OpenNotes.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-OpenNotes.a"; sourceTree = BUILT_PRODUCTS_DIR; };
3034
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = OpenNotes/SplashScreen.storyboard; sourceTree = "<group>"; };
@@ -54,6 +58,8 @@
5458
F11748412D0307B40044C1D9 /* AppDelegate.swift */,
5559
7D3A80142E1A4CC89B1B09C1 /* PDFUtilsModule.swift */,
5660
7D3A80152E1A4CC89B1B09C1 /* PDFUtilsModule.m */,
61+
7D3A80182E1A4CC89B1B09C1 /* ICloudBackupModule.swift */,
62+
7D3A80192E1A4CC89B1B09C1 /* ICloudBackupModule.m */,
5763
F11748442D0722820044C1D9 /* OpenNotes-Bridging-Header.h */,
5864
BB2F792B24A3F905000567C9 /* Supporting */,
5965
13B07FB51A68108700A75B9A /* Images.xcassets */,
@@ -338,6 +344,8 @@
338344
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */,
339345
7D3A80162E1A4CC89B1B09C1 /* PDFUtilsModule.swift in Sources */,
340346
7D3A80172E1A4CC89B1B09C1 /* PDFUtilsModule.m in Sources */,
347+
7D3A801A2E1A4CC89B1B09C1 /* ICloudBackupModule.swift in Sources */,
348+
7D3A801B2E1A4CC89B1B09C1 /* ICloudBackupModule.m in Sources */,
341349
4788858903E15B3CAEB129E6 /* ExpoModulesProvider.swift in Sources */,
342350
);
343351
runOnlyForDeploymentPostprocessing = 0;

ios/OpenNotes/ICloudBackupModule.m

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
#import <React/RCTBridgeModule.h>
2+
3+
@interface RCT_EXTERN_MODULE(ICloudBackupModule, NSObject)
4+
5+
RCT_EXTERN_METHOD(getContainerPath:(RCTPromiseResolveBlock)resolver
6+
rejecter:(RCTPromiseRejectBlock)rejecter)
7+
8+
RCT_EXTERN_METHOD(copyItem:(NSString *)from
9+
to:(NSString *)to
10+
resolver:(RCTPromiseResolveBlock)resolver
11+
rejecter:(RCTPromiseRejectBlock)rejecter)
12+
13+
RCT_EXTERN_METHOD(writeFileAtomic:(NSString *)path
14+
contents:(NSString *)contents
15+
resolver:(RCTPromiseResolveBlock)resolver
16+
rejecter:(RCTPromiseRejectBlock)rejecter)
17+
18+
RCT_EXTERN_METHOD(readFileAsString:(NSString *)path
19+
resolver:(RCTPromiseResolveBlock)resolver
20+
rejecter:(RCTPromiseRejectBlock)rejecter)
21+
22+
RCT_EXTERN_METHOD(deleteItem:(NSString *)path
23+
resolver:(RCTPromiseResolveBlock)resolver
24+
rejecter:(RCTPromiseRejectBlock)rejecter)
25+
26+
RCT_EXTERN_METHOD(listFilesRecursive:(NSString *)dir
27+
resolver:(RCTPromiseResolveBlock)resolver
28+
rejecter:(RCTPromiseRejectBlock)rejecter)
29+
30+
RCT_EXTERN_METHOD(ensureDownloaded:(NSString *)path
31+
timeoutMs:(nonnull NSNumber *)timeoutMs
32+
resolver:(RCTPromiseResolveBlock)resolver
33+
rejecter:(RCTPromiseRejectBlock)rejecter)
34+
35+
@end
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import Foundation
2+
import React
3+
4+
@objc(ICloudBackupModule)
5+
class ICloudBackupModule: NSObject {
6+
@objc static func requiresMainQueueSetup() -> Bool {
7+
return false
8+
}
9+
10+
/// Resolves the app's iCloud Drive container Documents directory, creating
11+
/// it if needed. Resolves null when iCloud is unavailable (signed out, iCloud
12+
/// Drive disabled, or missing entitlement) - callers treat that as "backup
13+
/// unavailable", never as an error.
14+
@objc
15+
func getContainerPath(_ resolver: @escaping RCTPromiseResolveBlock,
16+
rejecter: @escaping RCTPromiseRejectBlock) {
17+
DispatchQueue.global(qos: .utility).async {
18+
guard let containerUrl = FileManager.default.url(forUbiquityContainerIdentifier: nil) else {
19+
resolver(NSNull())
20+
return
21+
}
22+
let documentsUrl = containerUrl.appendingPathComponent("Documents", isDirectory: true)
23+
do {
24+
try FileManager.default.createDirectory(
25+
at: documentsUrl,
26+
withIntermediateDirectories: true
27+
)
28+
} catch {
29+
rejecter("E_CONTAINER_DIR", "Could not create iCloud Documents directory", error)
30+
return
31+
}
32+
resolver(documentsUrl.path)
33+
}
34+
}
35+
36+
/// File operations below use FileManager because expo-file-system refuses
37+
/// paths outside the app sandbox scopes - which includes the ubiquity
38+
/// container. All backup-side I/O must therefore go through this module.
39+
40+
@objc
41+
func copyItem(_ from: String, to: String,
42+
resolver: @escaping RCTPromiseResolveBlock,
43+
rejecter: @escaping RCTPromiseRejectBlock) {
44+
DispatchQueue.global(qos: .utility).async {
45+
let fromUrl = URL(fileURLWithPath: Self.plainPath(from))
46+
let toUrl = URL(fileURLWithPath: Self.plainPath(to))
47+
do {
48+
try FileManager.default.createDirectory(
49+
at: toUrl.deletingLastPathComponent(),
50+
withIntermediateDirectories: true
51+
)
52+
if FileManager.default.fileExists(atPath: toUrl.path) {
53+
try FileManager.default.removeItem(at: toUrl)
54+
}
55+
try FileManager.default.copyItem(at: fromUrl, to: toUrl)
56+
resolver(true)
57+
} catch {
58+
rejecter("E_COPY", "Copy failed: \(fromUrl.lastPathComponent)", error)
59+
}
60+
}
61+
}
62+
63+
@objc
64+
func writeFileAtomic(_ path: String, contents: String,
65+
resolver: @escaping RCTPromiseResolveBlock,
66+
rejecter: @escaping RCTPromiseRejectBlock) {
67+
DispatchQueue.global(qos: .utility).async {
68+
let url = URL(fileURLWithPath: Self.plainPath(path))
69+
do {
70+
try FileManager.default.createDirectory(
71+
at: url.deletingLastPathComponent(),
72+
withIntermediateDirectories: true
73+
)
74+
try contents.write(to: url, atomically: true, encoding: .utf8)
75+
resolver(true)
76+
} catch {
77+
rejecter("E_WRITE", "Write failed: \(url.lastPathComponent)", error)
78+
}
79+
}
80+
}
81+
82+
@objc
83+
func readFileAsString(_ path: String,
84+
resolver: @escaping RCTPromiseResolveBlock,
85+
rejecter: @escaping RCTPromiseRejectBlock) {
86+
DispatchQueue.global(qos: .utility).async {
87+
let url = URL(fileURLWithPath: Self.plainPath(path))
88+
guard FileManager.default.fileExists(atPath: url.path) else {
89+
resolver(NSNull())
90+
return
91+
}
92+
do {
93+
resolver(try String(contentsOf: url, encoding: .utf8))
94+
} catch {
95+
rejecter("E_READ", "Read failed: \(url.lastPathComponent)", error)
96+
}
97+
}
98+
}
99+
100+
@objc
101+
func deleteItem(_ path: String,
102+
resolver: @escaping RCTPromiseResolveBlock,
103+
rejecter: @escaping RCTPromiseRejectBlock) {
104+
DispatchQueue.global(qos: .utility).async {
105+
let url = URL(fileURLWithPath: Self.plainPath(path))
106+
do {
107+
if FileManager.default.fileExists(atPath: url.path) {
108+
try FileManager.default.removeItem(at: url)
109+
}
110+
resolver(true)
111+
} catch {
112+
rejecter("E_DELETE", "Delete failed: \(url.lastPathComponent)", error)
113+
}
114+
}
115+
}
116+
117+
/// Lists every regular file under `dir` recursively. Returns
118+
/// [{ rel, size, mtimeMs }] with `rel` relative to `dir`.
119+
@objc
120+
func listFilesRecursive(_ dir: String,
121+
resolver: @escaping RCTPromiseResolveBlock,
122+
rejecter: @escaping RCTPromiseRejectBlock) {
123+
DispatchQueue.global(qos: .utility).async {
124+
let baseUrl = URL(fileURLWithPath: Self.plainPath(dir))
125+
guard FileManager.default.fileExists(atPath: baseUrl.path) else {
126+
resolver([])
127+
return
128+
}
129+
guard let enumerator = FileManager.default.enumerator(
130+
at: baseUrl,
131+
includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey]
132+
) else {
133+
resolver([])
134+
return
135+
}
136+
var out: [[String: Any]] = []
137+
let basePath = baseUrl.path.hasSuffix("/") ? baseUrl.path : baseUrl.path + "/"
138+
for case let fileUrl as URL in enumerator {
139+
guard let values = try? fileUrl.resourceValues(
140+
forKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey]
141+
), values.isRegularFile == true else { continue }
142+
guard fileUrl.path.hasPrefix(basePath) else { continue }
143+
let rel = String(fileUrl.path.dropFirst(basePath.count))
144+
let mtimeMs = (values.contentModificationDate?.timeIntervalSince1970 ?? 0) * 1000
145+
out.append([
146+
"rel": rel,
147+
"size": values.fileSize ?? 0,
148+
"mtimeMs": Int(mtimeMs.rounded()),
149+
])
150+
}
151+
resolver(out)
152+
}
153+
}
154+
155+
private static func plainPath(_ path: String) -> String {
156+
if path.hasPrefix("file://"), let url = URL(string: path) {
157+
return url.path
158+
}
159+
return path
160+
}
161+
162+
/// Ensures a file in the ubiquity container is downloaded locally (iCloud
163+
/// may have evicted it). Resolves true when the file is readable, false when
164+
/// the download did not complete within the timeout.
165+
@objc
166+
func ensureDownloaded(_ path: String,
167+
timeoutMs: NSNumber,
168+
resolver: @escaping RCTPromiseResolveBlock,
169+
rejecter: @escaping RCTPromiseRejectBlock) {
170+
DispatchQueue.global(qos: .utility).async {
171+
let url = URL(fileURLWithPath: Self.plainPath(path))
172+
let fileManager = FileManager.default
173+
174+
if fileManager.fileExists(atPath: url.path) {
175+
resolver(true)
176+
return
177+
}
178+
179+
// A not-yet-downloaded ubiquitous file appears as ".<name>.icloud".
180+
let placeholderName = ".\(url.lastPathComponent).icloud"
181+
let placeholderUrl = url.deletingLastPathComponent().appendingPathComponent(placeholderName)
182+
guard fileManager.fileExists(atPath: placeholderUrl.path) else {
183+
resolver(false)
184+
return
185+
}
186+
187+
do {
188+
try fileManager.startDownloadingUbiquitousItem(at: url)
189+
} catch {
190+
rejecter("E_DOWNLOAD_START", "Could not start iCloud download for \(url.lastPathComponent)", error)
191+
return
192+
}
193+
194+
let deadline = Date().addingTimeInterval(timeoutMs.doubleValue / 1000.0)
195+
while Date() < deadline {
196+
if fileManager.fileExists(atPath: url.path) {
197+
resolver(true)
198+
return
199+
}
200+
Thread.sleep(forTimeInterval: 0.2)
201+
}
202+
resolver(false)
203+
}
204+
}
205+
}
Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
<?xml version="1.0" encoding="UTF-8"?>
22
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
33
<plist version="1.0">
4-
<dict/>
5-
</plist>
4+
<dict>
5+
<key>com.apple.developer.icloud-container-identifiers</key>
6+
<array>
7+
<string>iCloud.com.builderpro.opennotes</string>
8+
</array>
9+
<key>com.apple.developer.icloud-services</key>
10+
<array>
11+
<string>CloudDocuments</string>
12+
</array>
13+
<key>com.apple.developer.ubiquity-container-identifiers</key>
14+
<array>
15+
<string>iCloud.com.builderpro.opennotes</string>
16+
</array>
17+
</dict>
18+
</plist>

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
"android": "expo run:android",
2020
"test:lifecycle": "node --experimental-strip-types --test scripts/lifecyclePolicy.test.mjs",
2121
"test:catalog": "node --experimental-strip-types --test scripts/catalogStore.test.mjs",
22-
"test": "node --experimental-strip-types --test scripts/lifecyclePolicy.test.mjs scripts/catalogStore.test.mjs",
22+
"test": "node --experimental-strip-types --test scripts/lifecyclePolicy.test.mjs scripts/catalogStore.test.mjs scripts/backupEngine.test.mjs",
23+
"test:backup": "node --experimental-strip-types --test scripts/backupEngine.test.mjs",
2324
"typecheck": "tsc --noEmit -p tsconfig.json"
2425
},
2526
"dependencies": {

0 commit comments

Comments
 (0)