diff --git a/Strand/Data/BackupSync.swift b/Strand/Data/BackupSync.swift index a577e14f2..07adf03e8 100644 --- a/Strand/Data/BackupSync.swift +++ b/Strand/Data/BackupSync.swift @@ -113,6 +113,7 @@ enum FolderBackup { private static let autoKey = "backupSync.auto" private static let lastKey = "backupSync.lastMs" private static let keepKey = "backupSync.keepCount" + private static let internalKey = "backupSync.useInternalFolder" // #52 picker-free fallback /// Default snapshots kept by prune: 7, i.e. a week of daily rollback points. (Mirrors the Android /// DEFAULT_KEEP; the Android keep-count is likewise user-adjustable.) @@ -141,10 +142,44 @@ enum FolderBackup { } static var lastBackupMs: Int { UserDefaults.standard.integer(forKey: lastKey) } - static var hasFolder: Bool { UserDefaults.standard.data(forKey: bookmarkKey) != nil } + static var hasFolder: Bool { useInternalFolder || UserDefaults.standard.data(forKey: bookmarkKey) != nil } + + /// #52: on some iOS 26 builds the system folder picker's "Open" button never enables/fires, so users + /// can't choose an external folder at all (three reports, works for one). This opt-in falls back to + /// NOOP's OWN Documents/Backups folder — already exposed in Files (UIFileSharingEnabled + + /// LSSupportsOpeningDocumentsInPlace) under "On My iPhone → NOOP" — so Backup & Sync works with zero + /// dependence on the picker. No security-scoped bookmark is involved (the folder is inside our own + /// sandbox), so `resolveFolder`/`saveFolder`'s scoped-access brackets simply no-op for it. The user + /// can drag that folder into iCloud Drive to read backups on the Mac; a first-class iCloud container + /// is a separate, larger change. An explicit external pick (`saveFolder`) turns this back off. + static var useInternalFolder: Bool { + get { UserDefaults.standard.bool(forKey: internalKey) } + set { UserDefaults.standard.set(newValue, forKey: internalKey) } + } + + /// Opt into backing up inside NOOP's own Files-visible folder (see `useInternalFolder`). Returns the + /// folder URL so the caller can refresh its label. iOS-only in practice; harmless elsewhere. + @discardableResult + static func useNoopFolder() -> URL? { + useInternalFolder = true + return internalFolderURL() + } - /// A short, human label for the chosen folder (its last path component), or nil if none chosen. - static func folderLabel() -> String? { resolveFolder()?.lastPathComponent } + /// NOOP's own Files-visible backup folder: `/Documents/Backups`, created on first use. + /// Returns nil only if Documents can't be located (never in practice). + private static func internalFolderURL() -> URL? { + guard let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return nil } + let dir = docs.appendingPathComponent("Backups", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + /// A short, human label for the chosen folder, or nil if none chosen. The internal fallback gets a + /// friendly name instead of the raw "Backups" path component. + static func folderLabel() -> String? { + if useInternalFolder { return String(localized: "NOOP (in Files)") } + return resolveFolder()?.lastPathComponent + } // MARK: - Security-scoped bookmark @@ -157,6 +192,7 @@ enum FolderBackup { } private static func resolveFolder() -> URL? { + if useInternalFolder { return internalFolderURL() } // #52: no bookmark — our own sandbox folder guard let data = UserDefaults.standard.data(forKey: bookmarkKey) else { return nil } var stale = false #if os(macOS) @@ -175,10 +211,18 @@ enum FolderBackup { static func saveFolder(_ url: URL) { let scoped = url.startAccessingSecurityScopedResource() defer { if scoped { url.stopAccessingSecurityScopedResource() } } - if let data = try? url.bookmarkData(options: bookmarkCreationOptions(), - includingResourceValuesForKeys: nil, relativeTo: nil) { + let data = try? url.bookmarkData(options: bookmarkCreationOptions(), + includingResourceValuesForKeys: nil, relativeTo: nil) + if let data { UserDefaults.standard.set(data, forKey: bookmarkKey) + useInternalFolder = false // #52: an explicit external pick overrides the internal fallback } + // #52 instrumentation: record whether scoped access opened and whether the bookmark actually + // minted, so a debug export can tell a folder that failed to persist HERE apart from a picker + // that never returned a URL at all. Cheap UserDefaults writes; see DebugDataDiagnostics. + let d = UserDefaults.standard + d.set(scoped, forKey: "backupPicker.lastScopedOpen") + d.set(data != nil, forKey: "backupPicker.lastBookmarkOk") } // MARK: - Backup / prune diff --git a/Strand/Screens/BackupSyncView.swift b/Strand/Screens/BackupSyncView.swift index ee2c8460b..fb12019f5 100644 --- a/Strand/Screens/BackupSyncView.swift +++ b/Strand/Screens/BackupSyncView.swift @@ -78,6 +78,15 @@ struct BackupSyncView: View { NoopButton(folderLabel == nil ? "Choose folder" : "Change folder", systemImage: "folder", kind: .secondary) { chooseFolder() } .disabled(busy) + #if os(iOS) + // #52: some iOS 26 users can't select a folder in the system picker (its "Open" button + // never fires). This backs up inside NOOP's own Files-visible folder instead — no picker. + if !FolderBackup.useInternalFolder { + NoopButton("Use NOOP's own folder (browse in Files)", + systemImage: "iphone", kind: .tertiary) { useNoopFolder() } + .disabled(busy) + } + #endif } } } @@ -155,15 +164,30 @@ struct BackupSyncView: View { Task { if await FolderBackup.pickFolder() != nil { folderLabel = FolderBackup.folderLabel() - } else { + } else if !FolderBackup.useInternalFolder { + // Only nag when there's no working destination. If the internal fallback is already + // active, a cancelled picker changed nothing — and the button the message points at is + // hidden, so alerting here would send the user chasing a control that isn't shown. alertTitle = String(localized: "No folder selected") - alertMessage = String(localized: "NOOP didn't get a folder back from the picker. If the Select button won't enable, try creating a fresh folder in Files (under On My iPhone or iCloud Drive) and choosing that instead.") + alertMessage = String(localized: "NOOP didn't get a folder back from the picker. If the Open button won't do anything, tap \"Use NOOP's own folder\" below to back up inside NOOP instead — you can read those backups from the Files app.") showAlert = true } } #endif } + #if os(iOS) + // #52: picker-free fallback. Back up inside NOOP's own Files-visible folder (On My iPhone → NOOP → + // Backups). No folder picker, no security-scoped bookmark — works even where the picker won't select. + private func useNoopFolder() { + FolderBackup.useNoopFolder() + folderLabel = FolderBackup.folderLabel() + alertTitle = String(localized: "Using NOOP's folder") + alertMessage = String(localized: "Backups will be saved inside NOOP. Open the Files app → On My iPhone → NOOP → Backups to see them, or drag that folder into iCloud Drive to read it on your Mac. To use a different folder later, tap Change folder.") + showAlert = true + } + #endif + private func backupNow() { busy = true Task { diff --git a/Strand/System/DebugDataDiagnostics.swift b/Strand/System/DebugDataDiagnostics.swift index 8f3c44fc0..94183b5a9 100644 --- a/Strand/System/DebugDataDiagnostics.swift +++ b/Strand/System/DebugDataDiagnostics.swift @@ -45,6 +45,21 @@ enum DebugDataDiagnostics { + "(if you restored a backup, fully restart the app — #57)") } if restoreAt > 0 { lines.append("Last restore: \(relTime(now - restoreAt))") } + #if os(iOS) + // #52: iOS Backup & Sync folder-picker health. When users report "won't let me pick a folder", + // this pins the failure stage: "cancelled"/"never used" ⇒ the picker's Open button never fired + // (an iOS-side picker issue — the in-app "Use NOOP's own folder" fallback sidesteps it); + // "picked" + a FAILED flag ⇒ a returned folder failed to bookmark HERE (our bug). + let pickEvent = d.string(forKey: "backupPicker.lastEvent") ?? "never used" + let pickAt = d.double(forKey: "backupPicker.lastEventAt") + lines.append("Folder picker: \(pickEvent)\(pickAt > 0 ? " (\(relTime(now - pickAt)))" : "")") + if pickEvent == "picked" { + let scoped = d.bool(forKey: "backupPicker.lastScopedOpen") + let bmOk = d.bool(forKey: "backupPicker.lastBookmarkOk") + lines.append(" scoped-access \(scoped ? "ok" : "FAILED"), bookmark \(bmOk ? "ok" : "FAILED")") + } + lines.append("Backup mode: \(FolderBackup.useInternalFolder ? "NOOP's own folder (#52 fallback)" : (FolderBackup.hasFolder ? "external folder" : "none chosen"))") + #endif lines.append("Timezone: \(tzLine())") return lines } diff --git a/Strand/System/DocumentPicker.swift b/Strand/System/DocumentPicker.swift index e5a59c012..e6ca4b520 100644 --- a/Strand/System/DocumentPicker.swift +++ b/Strand/System/DocumentPicker.swift @@ -96,10 +96,12 @@ enum DocumentPicker { } func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + DocumentPicker.recordEvent(urls.isEmpty ? "picked-empty" : "picked", url: urls.first) finish(urls.first) } func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + DocumentPicker.recordEvent("cancelled", url: nil) finish(nil) } @@ -109,5 +111,17 @@ enum DocumentPicker { continuation.resume(returning: url) } } + + /// #52 instrumentation: persist the last picker delegate outcome so a debug export can distinguish + /// "the picker never called back / user cancelled" (its Open button never fired — an iOS picker + /// issue the internal-folder fallback sidesteps) from "it returned a URL we then failed to bookmark" + /// (our bug, see `FolderBackup.saveFolder`'s scoped/bookmark flags). Shared by all three pickers, so + /// the export labels it "last picker event"; the folder pick is the one under investigation. + static func recordEvent(_ kind: String, url: URL?) { + let d = UserDefaults.standard + d.set(kind, forKey: "backupPicker.lastEvent") + d.set(Date().timeIntervalSince1970, forKey: "backupPicker.lastEventAt") + d.set(url?.lastPathComponent ?? "", forKey: "backupPicker.lastName") + } } #endif