diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index d99b6294..a422a23a 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -12147,6 +12147,28 @@ } } }, + "settings.icloud.unavailable" : { + "comment" : "Label shown instead of the iCloud sync toggle when the app installation lacks iCloud entitlements", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "iCloud Sync Unavailable" + } + } + } + }, + "settings.icloud.unavailable.footer" : { + "comment" : "Footer explaining why iCloud sync is unavailable in sideloaded installations", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This copy of Yattee was installed without iCloud access, so sync is not available. iCloud sync requires installing the app from the App Store or TestFlight." + } + } + } + }, "settings.icloud.update.message" : { "comment" : "Message explaining that newer app version is needed for some synced data", "localizations" : { diff --git a/Yattee/Services/CloudKit/CloudKitAvailability.swift b/Yattee/Services/CloudKit/CloudKitAvailability.swift new file mode 100644 index 00000000..ceb1e9bc --- /dev/null +++ b/Yattee/Services/CloudKit/CloudKitAvailability.swift @@ -0,0 +1,51 @@ +// +// CloudKitAvailability.swift +// Yattee +// +// Detects whether the app was signed with the iCloud container entitlement. +// + +import Foundation + +/// Sideloaded installs (AltStore, SideStore, Sideloadly, …) are re-signed by a +/// team that cannot register `iCloud.stream.yattee.app` (container IDs are +/// globally unique), so the iCloud entitlements are stripped or remapped. +/// `CKContainer(identifier:)` fatally traps in that state, so the container +/// must never be created when the entitlement is missing. +enum CloudKitAvailability { + /// Whether CloudKit APIs may be used in this installation. + /// + /// Determined by parsing the embedded provisioning profile. Builds without + /// an embedded profile (App Store, TestFlight, simulator) always carry the + /// correct entitlements and are treated as available. + static let isAvailable: Bool = { + guard let entitlements = embeddedProvisioningEntitlements() else { + return true + } + let containers = entitlements["com.apple.developer.icloud-container-identifiers"] as? [String] ?? [] + return containers.contains(AppIdentifiers.iCloudContainer) + }() + + /// Extracts the entitlements dictionary from the embedded provisioning + /// profile — a CMS blob wrapping an XML property list. + private static func embeddedProvisioningEntitlements() -> [String: Any]? { + #if os(macOS) + let url = Bundle.main.bundleURL.appendingPathComponent("Contents/embedded.provisionprofile") + #else + guard let path = Bundle.main.path(forResource: "embedded", ofType: "mobileprovision") else { return nil } + let url = URL(fileURLWithPath: path) + #endif + + guard let data = try? Data(contentsOf: url), + let start = data.range(of: Data("".utf8), in: start.lowerBound.. Bool { + guard let container else { return false } do { // Fetch the current user's record ID using async wrapper let currentUserRecordID = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in @@ -1422,6 +1446,8 @@ final class CloudKitSyncEngine: @unchecked Sendable { /// Clear all sync state and reset. For testing/debugging only. func resetSync() async throws { + guard let zoneManager else { return } + // Delete zone (and all records) try await zoneManager.deleteZone() @@ -1449,6 +1475,7 @@ final class CloudKitSyncEngine: @unchecked Sendable { /// Refreshes the cached iCloud account status func refreshAccountStatus() async { + guard let container else { return } do { accountStatus = try await container.accountStatus() } catch { @@ -1566,7 +1593,7 @@ final class CloudKitSyncEngine: @unchecked Sendable { case .zoneNotFound: LoggingService.shared.logCloudKit("Zone not found, recreating...") Task { - try? await zoneManager.createZoneIfNeeded() + try? await zoneManager?.createZoneIfNeeded() await sync() } @@ -1882,7 +1909,7 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate { // Zone was deleted — recreate it and retry the save syncEngine?.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) Task { - try? await zoneManager.createZoneIfNeeded() + try? await zoneManager?.createZoneIfNeeded() } LoggingService.shared.logCloudKit("Zone missing for \(recordName), recreating and retrying") @@ -1966,7 +1993,7 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate { retryCount.removeAll() do { - try await zoneManager.createZoneIfNeeded() + try await zoneManager?.createZoneIfNeeded() await performInitialUpload() } catch { LoggingService.shared.logCloudKitError("Failed to recreate zone after remote deletion", error: error) diff --git a/Yattee/Views/Settings/iCloudSettingsView.swift b/Yattee/Views/Settings/iCloudSettingsView.swift index 0b6e0bfd..cff5dc85 100644 --- a/Yattee/Views/Settings/iCloudSettingsView.swift +++ b/Yattee/Views/Settings/iCloudSettingsView.swift @@ -58,24 +58,31 @@ struct iCloudSettingsView: View { } #endif - SettingsFormSection(footer: "settings.icloud.footer") { - Toggle(isOn: Binding( - get: { settingsManager?.iCloudSyncEnabled ?? false }, - set: { newValue in - if newValue { - showingEnableConfirmation = true - } else { - showingDisableConfirmation = true - } - } - )) { - Label(String(localized: "settings.icloud.enable"), systemImage: "icloud") + if cloudKitSync?.isCloudKitAvailable == false { + SettingsFormSection(footer: "settings.icloud.unavailable.footer") { + Label(String(localized: "settings.icloud.unavailable"), systemImage: "icloud.slash") + .foregroundStyle(.secondary) + } + } else { + SettingsFormSection(footer: "settings.icloud.footer") { + Toggle(isOn: Binding( + get: { settingsManager?.iCloudSyncEnabled ?? false }, + set: { newValue in + if newValue { + showingEnableConfirmation = true + } else { + showingDisableConfirmation = true + } + } + )) { + Label(String(localized: "settings.icloud.enable"), systemImage: "icloud") + } } - } - if settingsManager?.iCloudSyncEnabled == true { - syncCategoriesSection - syncStatusSection + if settingsManager?.iCloudSyncEnabled == true { + syncCategoriesSection + syncStatusSection + } } } #if !os(tvOS)