Skip to content

Commit 6cee2c6

Browse files
committed
fix(backup): file-coordinated container I/O so iCloud actually uploads (build 12)
Live observation from a second device on the same Apple ID proved the container's directory skeleton synced but no file ever reached the server - a 30-minute watch during a forced full re-mirror recorded zero arrivals. Uncoordinated writes (atomic rename-into-place in particular) are not reliably seen by the iCloud sync daemon. - All container mutations now run under NSFileCoordinator (.forReplacing / .forDeleting); the catalog/manifest write is a coordinated in-place data write instead of String.write(atomically:), whose rename is what left files invisible to the daemon. - New uploadStatus (NSMetadataUbiquitousItemIsUploadedKey) reports how much of the backup Apple's servers actually hold; the support-sheet status line now says 'Backing up - N files still uploading to iCloud' (localized in all 13 languages) until everything is durable, instead of implying safety while data sits only in the local replica. Simulator smoke: the coordinated paths produce a byte-identical mirror. Real-iCloud validation: live Mac-side container watch during a device re-mirror, which previously recorded zero uploads. Claude-Session: https://claude.ai/code/session_01Y9r9L2vDPQwyEEV61qWyfp
1 parent f304ef8 commit 6cee2c6

20 files changed

Lines changed: 172 additions & 12 deletions

File tree

‎android/app/build.gradle‎

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

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

‎app.json‎

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

‎ios/OpenNotes.xcodeproj/project.pbxproj‎

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

‎ios/OpenNotes/ICloudBackupModule.m‎

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

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

‎ios/OpenNotes/ICloudBackupModule.swift‎

Lines changed: 98 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,12 @@ class ICloudBackupModule: NSObject {
4949
at: toUrl.deletingLastPathComponent(),
5050
withIntermediateDirectories: true
5151
)
52-
if FileManager.default.fileExists(atPath: toUrl.path) {
53-
try FileManager.default.removeItem(at: toUrl)
52+
try Self.coordinatedWrite(toUrl, options: .forReplacing) { url in
53+
if FileManager.default.fileExists(atPath: url.path) {
54+
try FileManager.default.removeItem(at: url)
55+
}
56+
try FileManager.default.copyItem(at: fromUrl, to: url)
5457
}
55-
try FileManager.default.copyItem(at: fromUrl, to: toUrl)
5658
resolver(true)
5759
} catch {
5860
rejecter("E_COPY", "Copy failed: \(fromUrl.lastPathComponent)", error)
@@ -71,7 +73,16 @@ class ICloudBackupModule: NSObject {
7173
at: url.deletingLastPathComponent(),
7274
withIntermediateDirectories: true
7375
)
74-
try contents.write(to: url, atomically: true, encoding: .utf8)
76+
guard let data = contents.data(using: .utf8) else {
77+
rejecter("E_WRITE", "Write failed (encoding): \(url.lastPathComponent)", nil)
78+
return
79+
}
80+
// Deliberately NOT String.write(atomically:) - the rename-into-place
81+
// it performs is what left files invisible to the sync daemon. The
82+
// file coordinator provides the crash consistency instead.
83+
try Self.coordinatedWrite(url, options: .forReplacing) { coordUrl in
84+
try data.write(to: coordUrl, options: [])
85+
}
7586
resolver(true)
7687
} catch {
7788
rejecter("E_WRITE", "Write failed: \(url.lastPathComponent)", error)
@@ -105,7 +116,9 @@ class ICloudBackupModule: NSObject {
105116
let url = URL(fileURLWithPath: Self.plainPath(path))
106117
do {
107118
if FileManager.default.fileExists(atPath: url.path) {
108-
try FileManager.default.removeItem(at: url)
119+
try Self.coordinatedWrite(url, options: .forDeleting) { coordUrl in
120+
try FileManager.default.removeItem(at: coordUrl)
121+
}
109122
}
110123
resolver(true)
111124
} catch {
@@ -152,6 +165,25 @@ class ICloudBackupModule: NSObject {
152165
}
153166
}
154167

168+
/// All mutations inside the ubiquity container are file-coordinated:
169+
/// uncoordinated writes (and atomic rename-into-place in particular) are
170+
/// not reliably picked up by the iCloud sync daemon - observed in
171+
/// production as "directories sync, files never upload".
172+
private static func coordinatedWrite(
173+
_ url: URL, options: NSFileCoordinator.WritingOptions,
174+
_ body: (URL) throws -> Void
175+
) throws {
176+
var coordError: NSError?
177+
var innerError: Error?
178+
NSFileCoordinator(filePresenter: nil).coordinate(
179+
writingItemAt: url, options: options, error: &coordError
180+
) { coordinatedUrl in
181+
do { try body(coordinatedUrl) } catch { innerError = error }
182+
}
183+
if let error = coordError { throw error }
184+
if let error = innerError { throw error }
185+
}
186+
155187
private static func plainPath(_ path: String) -> String {
156188
if path.hasPrefix("file://"), let url = URL(string: path) {
157189
return url.path
@@ -187,6 +219,67 @@ class ICloudBackupModule: NSObject {
187219
}
188220
}
189221

222+
/// Reports how many files under `dir` iCloud has actually uploaded to the
223+
/// server, via NSMetadataUbiquitousItemIsUploadedKey. This is the only
224+
/// honest signal that a backup is durable - a file sitting in the local
225+
/// container replica is NOT safe until uploaded.
226+
@objc
227+
func uploadStatus(_ dir: String,
228+
timeoutMs: NSNumber,
229+
resolver: @escaping RCTPromiseResolveBlock,
230+
rejecter: @escaping RCTPromiseRejectBlock) {
231+
DispatchQueue.main.async {
232+
let resolvedBase = URL(fileURLWithPath: Self.plainPath(dir))
233+
.resolvingSymlinksInPath().path
234+
let basePath = resolvedBase.hasSuffix("/") ? resolvedBase : resolvedBase + "/"
235+
let query = NSMetadataQuery()
236+
query.searchScopes = [
237+
NSMetadataQueryUbiquitousDocumentsScope,
238+
NSMetadataQueryUbiquitousDataScope,
239+
]
240+
query.predicate = NSPredicate(format: "%K LIKE '*'", NSMetadataItemFSNameKey)
241+
242+
var finished = false
243+
var observer: NSObjectProtocol?
244+
func complete() {
245+
guard !finished else { return }
246+
finished = true
247+
query.disableUpdates()
248+
query.stop()
249+
if let obs = observer { NotificationCenter.default.removeObserver(obs) }
250+
var total = 0
251+
var uploaded = 0
252+
var pending: [String] = []
253+
for case let item as NSMetadataItem in query.results {
254+
guard let rawPath = item.value(forAttribute: NSMetadataItemPathKey) as? String
255+
else { continue }
256+
let itemPath = URL(fileURLWithPath: rawPath).resolvingSymlinksInPath().path
257+
guard itemPath.hasPrefix(basePath) else { continue }
258+
var isDir: ObjCBool = false
259+
if FileManager.default.fileExists(atPath: itemPath, isDirectory: &isDir),
260+
isDir.boolValue { continue }
261+
total += 1
262+
let isUploaded = (item.value(
263+
forAttribute: NSMetadataUbiquitousItemIsUploadedKey) as? NSNumber)?.boolValue ?? false
264+
if isUploaded {
265+
uploaded += 1
266+
} else {
267+
pending.append(String(itemPath.dropFirst(basePath.count)))
268+
}
269+
}
270+
resolver(["total": total, "uploaded": uploaded, "pending": pending])
271+
}
272+
273+
observer = NotificationCenter.default.addObserver(
274+
forName: .NSMetadataQueryDidFinishGathering, object: query, queue: .main
275+
) { _ in complete() }
276+
DispatchQueue.main.asyncAfter(
277+
deadline: .now() + timeoutMs.doubleValue / 1000.0
278+
) { complete() }
279+
query.start()
280+
}
281+
}
282+
190283
/// Lists every item iCloud knows about under the container's Documents dir
191284
/// via NSMetadataQuery - the canonical discovery API. Unlike a directory
192285
/// walk it sees items whose contents have not been downloaded yet, and

‎ios/OpenNotes/Info.plist‎

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

‎src/hooks/useBackup.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Alert, Platform } from 'react-native';
33
import { catalogStore } from '../services/catalogEnv';
44
import {
55
checkBackupRestoreAvailable,
6+
getBackupUploadStatus,
67
isBackupAvailable,
78
isBackupEnabled,
89
performBackupRestore,
@@ -43,6 +44,7 @@ export function useBackup(
4344
): UseBackupResult {
4445
const [enabled, setEnabled] = useState<boolean | null>(null);
4546
const [available, setAvailable] = useState<boolean | null>(null);
47+
const [pendingUploads, setPendingUploads] = useState(0);
4648
const restorePromptShownRef = useRef(false);
4749

4850
useEffect(() => {
@@ -56,6 +58,10 @@ export function useBackup(
5658
if (cancelled) return;
5759
setEnabled(isEnabled);
5860
setAvailable(isAvailable);
61+
if (isEnabled && isAvailable) {
62+
const status = await getBackupUploadStatus();
63+
if (!cancelled && status) setPendingUploads(status.pending);
64+
}
5965
})();
6066
return () => {
6167
cancelled = true;
@@ -156,7 +162,9 @@ export function useBackup(
156162
? t.backup.statusOff
157163
: available === false
158164
? t.backup.statusUnavailable
159-
: t.backup.statusAutomatic;
165+
: pendingUploads > 0
166+
? t.backup.statusUploading(pendingUploads)
167+
: t.backup.statusAutomatic;
160168

161169
return {
162170
backupSupported: BACKUP_SUPPORTED,

‎src/i18n/locales/de.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ export const de: Strings = {
9898
backup: {
9999
rowTitle: 'iCloud-Backup',
100100
statusAutomatic: 'Automatisch — deine Notizen überstehen das Löschen der App',
101+
statusUploading: (pending: number) =>
102+
`Sichert — ${pending} ${pending === 1 ? 'Datei wird' : 'Dateien werden'} noch zu iCloud hochgeladen`,
101103
statusUnavailable: 'iCloud nicht verfügbar — melde dich bei iCloud an, um deine Notizen zu schützen',
102104
statusOff: 'Aus — Notizen existieren nur auf diesem Gerät',
103105
turnOffTitle: 'iCloud-Backup ausschalten?',

‎src/i18n/locales/en.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ export const en = {
9898
backup: {
9999
rowTitle: 'iCloud backup',
100100
statusAutomatic: 'Automatic — your notes survive app deletion',
101+
statusUploading: (pending: number) =>
102+
`Backing up — ${pending} ${pending === 1 ? 'file' : 'files'} still uploading to iCloud`,
101103
statusUnavailable: 'iCloud unavailable — sign in to iCloud to protect your notes',
102104
statusOff: 'Off — notes exist only on this device',
103105
turnOffTitle: 'Turn off iCloud backup?',

‎src/i18n/locales/es.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ export const es: Strings = {
9898
backup: {
9999
rowTitle: 'Copia en iCloud',
100100
statusAutomatic: 'Automática — tus notas sobreviven si borras la app',
101+
statusUploading: (pending: number) =>
102+
`Copiando — ${pending} archivo${pending === 1 ? '' : 's'} aún subiendo a iCloud`,
101103
statusUnavailable: 'iCloud no disponible — inicia sesión en iCloud para proteger tus notas',
102104
statusOff: 'Desactivada — las notas solo existen en este dispositivo',
103105
turnOffTitle: '¿Desactivar la copia en iCloud?',

0 commit comments

Comments
 (0)