From 6cee2c631649a4f264e537a3bb334334208775ed Mon Sep 17 00:00:00 2001 From: markm39 Date: Sun, 30 Aug 2026 15:32:01 -0500 Subject: [PATCH] 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 --- android/app/build.gradle | 2 +- app.json | 4 +- ios/OpenNotes.xcodeproj/project.pbxproj | 4 +- ios/OpenNotes/ICloudBackupModule.m | 5 ++ ios/OpenNotes/ICloudBackupModule.swift | 103 ++++++++++++++++++++++-- ios/OpenNotes/Info.plist | 2 +- src/hooks/useBackup.ts | 10 ++- src/i18n/locales/de.ts | 2 + src/i18n/locales/en.ts | 2 + src/i18n/locales/es.ts | 2 + src/i18n/locales/fr.ts | 2 + src/i18n/locales/it.ts | 2 + src/i18n/locales/ja.ts | 2 + src/i18n/locales/ko.ts | 2 + src/i18n/locales/nl.ts | 2 + src/i18n/locales/pt.ts | 2 + src/i18n/locales/ru.ts | 2 + src/i18n/locales/zhHans.ts | 2 + src/i18n/locales/zhHant.ts | 2 + src/services/backupService.ts | 30 +++++++ 20 files changed, 172 insertions(+), 12 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index d330c91..0f5ff5f 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -92,7 +92,7 @@ android { applicationId 'com.builderpro.opennotes' minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 11 + versionCode 12 versionName "1.3" buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" diff --git a/app.json b/app.json index ec263e9..1e732b5 100644 --- a/app.json +++ b/app.json @@ -16,7 +16,7 @@ "ios": { "bundleIdentifier": "com.builderpro.opennotes", "icon": "./assets/icon.png", - "buildNumber": "11", + "buildNumber": "12", "supportsTablet": true, "usesIcloudStorage": true, "entitlements": { @@ -65,7 +65,7 @@ }, "android": { "package": "com.builderpro.opennotes", - "versionCode": 11, + "versionCode": 12, "adaptiveIcon": { "foregroundImage": "./assets/adaptive-icon.png", "backgroundColor": "#F7F7F4" diff --git a/ios/OpenNotes.xcodeproj/project.pbxproj b/ios/OpenNotes.xcodeproj/project.pbxproj index d71e9f4..53a338e 100644 --- a/ios/OpenNotes.xcodeproj/project.pbxproj +++ b/ios/OpenNotes.xcodeproj/project.pbxproj @@ -364,7 +364,7 @@ CODE_SIGN_ENTITLEMENTS = OpenNotes/OpenNotes.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 11; + CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_TEAM = U2CPXQV7AJ; ENABLE_BITCODE = NO; GCC_PREPROCESSOR_DEFINITIONS = ( @@ -403,7 +403,7 @@ CODE_SIGN_ENTITLEMENTS = OpenNotes/OpenNotes.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 11; + CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_TEAM = U2CPXQV7AJ; INFOPLIST_FILE = OpenNotes/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; diff --git a/ios/OpenNotes/ICloudBackupModule.m b/ios/OpenNotes/ICloudBackupModule.m index 7aaf7ad..8b3f146 100644 --- a/ios/OpenNotes/ICloudBackupModule.m +++ b/ios/OpenNotes/ICloudBackupModule.m @@ -32,6 +32,11 @@ @interface RCT_EXTERN_MODULE(ICloudBackupModule, NSObject) resolver:(RCTPromiseResolveBlock)resolver rejecter:(RCTPromiseRejectBlock)rejecter) +RCT_EXTERN_METHOD(uploadStatus:(NSString *)dir + timeoutMs:(nonnull NSNumber *)timeoutMs + resolver:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) + RCT_EXTERN_METHOD(listCloudFiles:(NSString *)dir timeoutMs:(nonnull NSNumber *)timeoutMs resolver:(RCTPromiseResolveBlock)resolver diff --git a/ios/OpenNotes/ICloudBackupModule.swift b/ios/OpenNotes/ICloudBackupModule.swift index 500d2c7..61c60c2 100644 --- a/ios/OpenNotes/ICloudBackupModule.swift +++ b/ios/OpenNotes/ICloudBackupModule.swift @@ -49,10 +49,12 @@ class ICloudBackupModule: NSObject { at: toUrl.deletingLastPathComponent(), withIntermediateDirectories: true ) - if FileManager.default.fileExists(atPath: toUrl.path) { - try FileManager.default.removeItem(at: toUrl) + try Self.coordinatedWrite(toUrl, options: .forReplacing) { url in + if FileManager.default.fileExists(atPath: url.path) { + try FileManager.default.removeItem(at: url) + } + try FileManager.default.copyItem(at: fromUrl, to: url) } - try FileManager.default.copyItem(at: fromUrl, to: toUrl) resolver(true) } catch { rejecter("E_COPY", "Copy failed: \(fromUrl.lastPathComponent)", error) @@ -71,7 +73,16 @@ class ICloudBackupModule: NSObject { at: url.deletingLastPathComponent(), withIntermediateDirectories: true ) - try contents.write(to: url, atomically: true, encoding: .utf8) + guard let data = contents.data(using: .utf8) else { + rejecter("E_WRITE", "Write failed (encoding): \(url.lastPathComponent)", nil) + return + } + // Deliberately NOT String.write(atomically:) - the rename-into-place + // it performs is what left files invisible to the sync daemon. The + // file coordinator provides the crash consistency instead. + try Self.coordinatedWrite(url, options: .forReplacing) { coordUrl in + try data.write(to: coordUrl, options: []) + } resolver(true) } catch { rejecter("E_WRITE", "Write failed: \(url.lastPathComponent)", error) @@ -105,7 +116,9 @@ class ICloudBackupModule: NSObject { let url = URL(fileURLWithPath: Self.plainPath(path)) do { if FileManager.default.fileExists(atPath: url.path) { - try FileManager.default.removeItem(at: url) + try Self.coordinatedWrite(url, options: .forDeleting) { coordUrl in + try FileManager.default.removeItem(at: coordUrl) + } } resolver(true) } catch { @@ -152,6 +165,25 @@ class ICloudBackupModule: NSObject { } } + /// All mutations inside the ubiquity container are file-coordinated: + /// uncoordinated writes (and atomic rename-into-place in particular) are + /// not reliably picked up by the iCloud sync daemon - observed in + /// production as "directories sync, files never upload". + private static func coordinatedWrite( + _ url: URL, options: NSFileCoordinator.WritingOptions, + _ body: (URL) throws -> Void + ) throws { + var coordError: NSError? + var innerError: Error? + NSFileCoordinator(filePresenter: nil).coordinate( + writingItemAt: url, options: options, error: &coordError + ) { coordinatedUrl in + do { try body(coordinatedUrl) } catch { innerError = error } + } + if let error = coordError { throw error } + if let error = innerError { throw error } + } + private static func plainPath(_ path: String) -> String { if path.hasPrefix("file://"), let url = URL(string: path) { return url.path @@ -187,6 +219,67 @@ class ICloudBackupModule: NSObject { } } + /// Reports how many files under `dir` iCloud has actually uploaded to the + /// server, via NSMetadataUbiquitousItemIsUploadedKey. This is the only + /// honest signal that a backup is durable - a file sitting in the local + /// container replica is NOT safe until uploaded. + @objc + func uploadStatus(_ dir: String, + timeoutMs: NSNumber, + resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock) { + DispatchQueue.main.async { + let resolvedBase = URL(fileURLWithPath: Self.plainPath(dir)) + .resolvingSymlinksInPath().path + let basePath = resolvedBase.hasSuffix("/") ? resolvedBase : resolvedBase + "/" + let query = NSMetadataQuery() + query.searchScopes = [ + NSMetadataQueryUbiquitousDocumentsScope, + NSMetadataQueryUbiquitousDataScope, + ] + query.predicate = NSPredicate(format: "%K LIKE '*'", NSMetadataItemFSNameKey) + + var finished = false + var observer: NSObjectProtocol? + func complete() { + guard !finished else { return } + finished = true + query.disableUpdates() + query.stop() + if let obs = observer { NotificationCenter.default.removeObserver(obs) } + var total = 0 + var uploaded = 0 + var pending: [String] = [] + for case let item as NSMetadataItem in query.results { + guard let rawPath = item.value(forAttribute: NSMetadataItemPathKey) as? String + else { continue } + let itemPath = URL(fileURLWithPath: rawPath).resolvingSymlinksInPath().path + guard itemPath.hasPrefix(basePath) else { continue } + var isDir: ObjCBool = false + if FileManager.default.fileExists(atPath: itemPath, isDirectory: &isDir), + isDir.boolValue { continue } + total += 1 + let isUploaded = (item.value( + forAttribute: NSMetadataUbiquitousItemIsUploadedKey) as? NSNumber)?.boolValue ?? false + if isUploaded { + uploaded += 1 + } else { + pending.append(String(itemPath.dropFirst(basePath.count))) + } + } + resolver(["total": total, "uploaded": uploaded, "pending": pending]) + } + + observer = NotificationCenter.default.addObserver( + forName: .NSMetadataQueryDidFinishGathering, object: query, queue: .main + ) { _ in complete() } + DispatchQueue.main.asyncAfter( + deadline: .now() + timeoutMs.doubleValue / 1000.0 + ) { complete() } + query.start() + } + } + /// Lists every item iCloud knows about under the container's Documents dir /// via NSMetadataQuery - the canonical discovery API. Unlike a directory /// walk it sees items whose contents have not been downloaded yet, and diff --git a/ios/OpenNotes/Info.plist b/ios/OpenNotes/Info.plist index e8d6c72..fa7730d 100644 --- a/ios/OpenNotes/Info.plist +++ b/ios/OpenNotes/Info.plist @@ -54,7 +54,7 @@ CFBundleVersion - 11 + 12 ITSAppUsesNonExemptEncryption LSMinimumSystemVersion diff --git a/src/hooks/useBackup.ts b/src/hooks/useBackup.ts index 864f041..f7875c6 100644 --- a/src/hooks/useBackup.ts +++ b/src/hooks/useBackup.ts @@ -3,6 +3,7 @@ import { Alert, Platform } from 'react-native'; import { catalogStore } from '../services/catalogEnv'; import { checkBackupRestoreAvailable, + getBackupUploadStatus, isBackupAvailable, isBackupEnabled, performBackupRestore, @@ -43,6 +44,7 @@ export function useBackup( ): UseBackupResult { const [enabled, setEnabled] = useState(null); const [available, setAvailable] = useState(null); + const [pendingUploads, setPendingUploads] = useState(0); const restorePromptShownRef = useRef(false); useEffect(() => { @@ -56,6 +58,10 @@ export function useBackup( if (cancelled) return; setEnabled(isEnabled); setAvailable(isAvailable); + if (isEnabled && isAvailable) { + const status = await getBackupUploadStatus(); + if (!cancelled && status) setPendingUploads(status.pending); + } })(); return () => { cancelled = true; @@ -156,7 +162,9 @@ export function useBackup( ? t.backup.statusOff : available === false ? t.backup.statusUnavailable - : t.backup.statusAutomatic; + : pendingUploads > 0 + ? t.backup.statusUploading(pendingUploads) + : t.backup.statusAutomatic; return { backupSupported: BACKUP_SUPPORTED, diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 9db41a5..7b8ada7 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -98,6 +98,8 @@ export const de: Strings = { backup: { rowTitle: 'iCloud-Backup', statusAutomatic: 'Automatisch — deine Notizen überstehen das Löschen der App', + statusUploading: (pending: number) => + `Sichert — ${pending} ${pending === 1 ? 'Datei wird' : 'Dateien werden'} noch zu iCloud hochgeladen`, statusUnavailable: 'iCloud nicht verfügbar — melde dich bei iCloud an, um deine Notizen zu schützen', statusOff: 'Aus — Notizen existieren nur auf diesem Gerät', turnOffTitle: 'iCloud-Backup ausschalten?', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index e3ad3ab..5b3d25b 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -98,6 +98,8 @@ export const en = { backup: { rowTitle: 'iCloud backup', statusAutomatic: 'Automatic — your notes survive app deletion', + statusUploading: (pending: number) => + `Backing up — ${pending} ${pending === 1 ? 'file' : 'files'} still uploading to iCloud`, statusUnavailable: 'iCloud unavailable — sign in to iCloud to protect your notes', statusOff: 'Off — notes exist only on this device', turnOffTitle: 'Turn off iCloud backup?', diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 832c989..e8d8dc4 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -98,6 +98,8 @@ export const es: Strings = { backup: { rowTitle: 'Copia en iCloud', statusAutomatic: 'Automática — tus notas sobreviven si borras la app', + statusUploading: (pending: number) => + `Copiando — ${pending} archivo${pending === 1 ? '' : 's'} aún subiendo a iCloud`, statusUnavailable: 'iCloud no disponible — inicia sesión en iCloud para proteger tus notas', statusOff: 'Desactivada — las notas solo existen en este dispositivo', turnOffTitle: '¿Desactivar la copia en iCloud?', diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 18e41b6..ca26165 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -98,6 +98,8 @@ export const fr: Strings = { backup: { rowTitle: 'Sauvegarde iCloud', statusAutomatic: 'Automatique — vos notes survivent à la suppression de l’app', + statusUploading: (pending: number) => + `Sauvegarde — ${pending} fichier${pending === 1 ? '' : 's'} encore en cours d’envoi vers iCloud`, statusUnavailable: 'iCloud indisponible — connectez-vous à iCloud pour protéger vos notes', statusOff: 'Désactivée — les notes n’existent que sur cet appareil', turnOffTitle: 'Désactiver la sauvegarde iCloud ?', diff --git a/src/i18n/locales/it.ts b/src/i18n/locales/it.ts index ac91e8b..655473c 100644 --- a/src/i18n/locales/it.ts +++ b/src/i18n/locales/it.ts @@ -98,6 +98,8 @@ export const it: Strings = { backup: { rowTitle: 'Backup iCloud', statusAutomatic: 'Automatico — le tue note sopravvivono all’eliminazione dell’app', + statusUploading: (pending: number) => + `Backup in corso — ${pending} file ancora in caricamento su iCloud`, statusUnavailable: 'iCloud non disponibile — accedi a iCloud per proteggere le tue note', statusOff: 'Disattivato — le note esistono solo su questo dispositivo', turnOffTitle: 'Disattivare il backup iCloud?', diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts index 6feddc5..fd3f06e 100644 --- a/src/i18n/locales/ja.ts +++ b/src/i18n/locales/ja.ts @@ -98,6 +98,8 @@ export const ja: Strings = { backup: { rowTitle: 'iCloudバックアップ', statusAutomatic: '自動 — アプリを削除してもノートは残ります', + statusUploading: (pending: number) => + `バックアップ中 — ${pending}個のファイルをiCloudにアップロード中`, statusUnavailable: 'iCloudを利用できません — iCloudにサインインしてノートを守りましょう', statusOff: 'オフ — ノートはこの端末にのみ存在します', turnOffTitle: 'iCloudバックアップをオフにしますか?', diff --git a/src/i18n/locales/ko.ts b/src/i18n/locales/ko.ts index 41d8959..d763390 100644 --- a/src/i18n/locales/ko.ts +++ b/src/i18n/locales/ko.ts @@ -98,6 +98,8 @@ export const ko: Strings = { backup: { rowTitle: 'iCloud 백업', statusAutomatic: '자동 — 앱을 삭제해도 노트가 남습니다', + statusUploading: (pending: number) => + `백업 중 — 파일 ${pending}개를 iCloud에 업로드하는 중`, statusUnavailable: 'iCloud 사용 불가 — iCloud에 로그인해 노트를 보호하세요', statusOff: '꺼짐 — 노트가 이 기기에만 존재합니다', turnOffTitle: 'iCloud 백업을 끌까요?', diff --git a/src/i18n/locales/nl.ts b/src/i18n/locales/nl.ts index caf7601..db0828c 100644 --- a/src/i18n/locales/nl.ts +++ b/src/i18n/locales/nl.ts @@ -98,6 +98,8 @@ export const nl: Strings = { backup: { rowTitle: 'iCloud-back-up', statusAutomatic: 'Automatisch — je notities overleven het verwijderen van de app', + statusUploading: (pending: number) => + `Back-up bezig — ${pending} bestand${pending === 1 ? ' wordt' : 'en worden'} nog geüpload naar iCloud`, statusUnavailable: 'iCloud niet beschikbaar — log in bij iCloud om je notities te beschermen', statusOff: 'Uit — notities bestaan alleen op dit apparaat', turnOffTitle: 'iCloud-back-up uitzetten?', diff --git a/src/i18n/locales/pt.ts b/src/i18n/locales/pt.ts index 7056d85..361bf2a 100644 --- a/src/i18n/locales/pt.ts +++ b/src/i18n/locales/pt.ts @@ -98,6 +98,8 @@ export const pt: Strings = { backup: { rowTitle: 'Backup no iCloud', statusAutomatic: 'Automático — suas notas sobrevivem se o app for apagado', + statusUploading: (pending: number) => + `Fazendo backup — ${pending} arquivo${pending === 1 ? '' : 's'} ainda enviando para o iCloud`, statusUnavailable: 'iCloud indisponível — entre no iCloud para proteger suas notas', statusOff: 'Desativado — as notas existem apenas neste aparelho', turnOffTitle: 'Desativar o backup no iCloud?', diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index d578195..3ffa035 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -107,6 +107,8 @@ export const ru: Strings = { backup: { rowTitle: 'Копия в iCloud', statusAutomatic: 'Автоматически — заметки переживут удаление приложения', + statusUploading: (pending: number) => + `Копирование — ещё ${pending} файл(ов) загружается в iCloud`, statusUnavailable: 'iCloud недоступен — войдите в iCloud, чтобы защитить заметки', statusOff: 'Выключено — заметки существуют только на этом устройстве', turnOffTitle: 'Выключить копию в iCloud?', diff --git a/src/i18n/locales/zhHans.ts b/src/i18n/locales/zhHans.ts index 46a2bdf..1d8c495 100644 --- a/src/i18n/locales/zhHans.ts +++ b/src/i18n/locales/zhHans.ts @@ -98,6 +98,8 @@ export const zhHans: Strings = { backup: { rowTitle: 'iCloud 备份', statusAutomatic: '自动 — 即使删除应用,笔记也会保留', + statusUploading: (pending: number) => + `备份中 — 还有 ${pending} 个文件正在上传到 iCloud`, statusUnavailable: 'iCloud 不可用 — 请登录 iCloud 以保护你的笔记', statusOff: '已关闭 — 笔记仅存在于此设备', turnOffTitle: '关闭 iCloud 备份?', diff --git a/src/i18n/locales/zhHant.ts b/src/i18n/locales/zhHant.ts index 0783e46..07d7b5d 100644 --- a/src/i18n/locales/zhHant.ts +++ b/src/i18n/locales/zhHant.ts @@ -98,6 +98,8 @@ export const zhHant: Strings = { backup: { rowTitle: 'iCloud 備份', statusAutomatic: '自動 — 即使刪除 App,筆記也會保留', + statusUploading: (pending: number) => + `備份中 — 還有 ${pending} 個檔案正在上傳到 iCloud`, statusUnavailable: 'iCloud 無法使用 — 請登入 iCloud 以保護你的筆記', statusOff: '已關閉 — 筆記僅存在於此裝置', turnOffTitle: '關閉 iCloud 備份?', diff --git a/src/services/backupService.ts b/src/services/backupService.ts index 5e9c1fb..3e65914 100644 --- a/src/services/backupService.ts +++ b/src/services/backupService.ts @@ -38,6 +38,10 @@ type ICloudBackupModuleType = { dir: string, timeoutMs: number, ) => Promise>; + uploadStatus?: ( + dir: string, + timeoutMs: number, + ) => Promise<{ total: number; uploaded: number; pending: string[] }>; }; const ICloudBackupModule = NativeModules.ICloudBackupModule as @@ -328,6 +332,32 @@ export async function isBackupAvailable(): Promise { return (await env.getContainerDir()) !== null; } +export interface BackupUploadStatus { + total: number; + uploaded: number; + pending: number; +} + +/** + * Asks iCloud how much of the mirrored backup has actually reached the + * server. Local container copies are NOT durable until uploaded - this is + * the only honest basis for telling the user their notes are safe. Returns + * null when unknowable (no module, dev override, container unavailable). + */ +export async function getBackupUploadStatus(): Promise { + if (!ICloudBackupModule?.uploadStatus) return null; + if (await devContainerOverride()) return null; + const containerDir = await env.getContainerDir(); + if (!containerDir) return null; + try { + const status = await ICloudBackupModule.uploadStatus(containerDir, 10000); + return { total: status.total, uploaded: status.uploaded, pending: status.pending.length }; + } catch (error) { + env.warn('[backupService] upload status query failed', error); + return null; + } +} + export async function setBackupEnabled(enabled: boolean): Promise { await AsyncStorage.setItem(ENABLED_KEY, enabled ? 'true' : 'false'); if (enabled) {