diff --git a/Catchlight/Resources/Info.plist b/Catchlight/Resources/Info.plist
index 0973494..83e85c4 100644
--- a/Catchlight/Resources/Info.plist
+++ b/Catchlight/Resources/Info.plist
@@ -35,6 +35,8 @@
$(CURRENT_PROJECT_VERSION)
ITSAppUsesNonExemptEncryption
+ LSRequiresIPhoneOS
+
NSFaceIDUsageDescription
Catchlight uses Face ID to unlock your encrypted Takes.
NSLocationWhenInUseUsageDescription
@@ -51,6 +53,10 @@
UIApplicationSupportsIndirectInputEvents
+ UIBackgroundModes
+
+ fetch
+
UILaunchStoryboardName
LaunchScreen
UIRequiresFullScreen
diff --git a/Catchlight/Resources/PrivacyInfo.xcprivacy b/Catchlight/Resources/PrivacyInfo.xcprivacy
index a816c20..2666c00 100644
--- a/Catchlight/Resources/PrivacyInfo.xcprivacy
+++ b/Catchlight/Resources/PrivacyInfo.xcprivacy
@@ -9,13 +9,15 @@
• No data collection at all — Takes are end-to-end encrypted on device and
the developer operates no server that ever receives user data.
• Required-reason APIs in use:
- - UserDefaults (NSPrivacyAccessedAPICategoryUserDefaults): app-group
- defaults for settings, device id, cloud-folder bookmark. Reason
- CA92.1 — "access info from same app, per documentation".
+ - UserDefaults (NSPrivacyAccessedAPICategoryUserDefaults): standard
+ defaults for settings (CA92.1 — same app only) AND the App-Group
+ suite shared with the widget extension for the capture hand-off
+ (1C8F.1 — app + extensions in the same App Group).
- File timestamps (NSPrivacyAccessedAPICategoryFileTimestamp): the
store and cloud-folder layers read/set file attributes inside the
- app's own containers and the user-picked sync folder. Reason
- C617.1 — "inside app/group container, per documentation".
+ app's own containers (C617.1) and read modification dates in the
+ USER-PICKED sync folder via the document-picker grant (3B52.1 —
+ files the user granted access to; see ImportCoordinator).
-->
@@ -33,6 +35,7 @@
NSPrivacyAccessedAPITypeReasons
CA92.1
+ 1C8F.1
@@ -41,6 +44,7 @@
NSPrivacyAccessedAPITypeReasons
C617.1
+ 3B52.1
diff --git a/Catchlight/Security/Keychain.swift b/Catchlight/Security/Keychain.swift
index a1efbd4..0b0a3f2 100644
--- a/Catchlight/Security/Keychain.swift
+++ b/Catchlight/Security/Keychain.swift
@@ -295,19 +295,29 @@ public struct MasterKeyKeychain {
let updateStatus = SecItemUpdate(searchQuery as CFDictionary, update as CFDictionary)
if updateStatus == errSecSuccess { return }
- guard updateStatus == errSecItemNotFound else {
- // e.g. errSecInteractionNotAllowed on an access-controlled item: fall
- // back to replacing the item outright with a minimal search query.
- let deleteQuery: [String: Any] = [
- kSecClass as String: kSecClassGenericPassword,
- kSecAttrService as String: service,
- kSecAttrAccount as String: account,
- kSecAttrAccessGroup as String: accessGroup
- ]
- SecItemDelete(deleteQuery as CFDictionary)
+ if updateStatus == errSecItemNotFound {
try addItem(data, accessControl: accessControl)
return
}
+ // Delete-then-add is a LAST resort for this item (2026-07-23, mirroring
+ // MnemonicKeychain.upsert's 2026-07-01 narrowing): a kill — or a failed
+ // add — between the delete and the add leaves NO master key stored, which
+ // presents as "needs onboarding" on a device that still holds encrypted
+ // Takes. Narrow the fallback to the single case it exists for —
+ // `errSecInteractionNotAllowed` (the item exists but can't be updated in
+ // the current protection state). Any OTHER update failure now throws so
+ // the caller sees it, instead of a blind delete opening a data-loss
+ // window on an unknown error.
+ guard updateStatus == errSecInteractionNotAllowed else {
+ throw KeychainError.storeFailed(updateStatus)
+ }
+ let deleteQuery: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: account,
+ kSecAttrAccessGroup as String: accessGroup
+ ]
+ SecItemDelete(deleteQuery as CFDictionary)
try addItem(data, accessControl: accessControl)
}
diff --git a/CatchlightWidgets/PrivacyInfo.xcprivacy b/CatchlightWidgets/PrivacyInfo.xcprivacy
new file mode 100644
index 0000000..69531f2
--- /dev/null
+++ b/CatchlightWidgets/PrivacyInfo.xcprivacy
@@ -0,0 +1,37 @@
+
+
+
+
+
+ NSPrivacyTracking
+
+ NSPrivacyTrackingDomains
+
+ NSPrivacyCollectedDataTypes
+
+ NSPrivacyAccessedAPITypes
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategoryUserDefaults
+ NSPrivacyAccessedAPITypeReasons
+
+ 1C8F.1
+
+
+
+
+
diff --git a/project.yml b/project.yml
index 6756105..e1daa73 100644
--- a/project.yml
+++ b/project.yml
@@ -136,6 +136,17 @@ targets:
# Info.plist").
BGTaskSchedulerPermittedIdentifiers:
- com.considus.catchlight.sync
+ # ALSO required for BGAppRefreshTask (2026-07-23 mid-point review): the
+ # permitted identifier above is necessary but NOT sufficient — without
+ # the Background fetch MODE, BGTaskScheduler.submit fails with
+ # BGTaskSchedulerErrorCodeUnavailable and background sync never runs
+ # (the exact silent failure BackgroundSync.scheduleNext's comment warns
+ # about).
+ UIBackgroundModes:
+ - fetch
+ # Standard iOS-bundle marker. Xcode-generated plists always carry it;
+ # this custom plist didn't, and upload validation can reject without it.
+ LSRequiresIPhoneOS: true
# Task 6.19 — Spotlight deep-link activity type. iOS uses this to
# dispatch `application(_:continue:)` (SwiftUI's
# `.onContinueUserActivity`) when the user taps a Catchlight result