Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 49 additions & 5 deletions Strand/Data/BackupSync.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
Expand Down Expand Up @@ -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: `<sandbox>/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

Expand All @@ -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)
Expand All @@ -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
Expand Down
28 changes: 26 additions & 2 deletions Strand/Screens/BackupSyncView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
Expand Down Expand Up @@ -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 {
Expand Down
15 changes: 15 additions & 0 deletions Strand/System/DebugDataDiagnostics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
14 changes: 14 additions & 0 deletions Strand/System/DocumentPicker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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
Loading