From 40f043b7695a0bc9af088ae62addf57dd2980317 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Mon, 27 Jul 2026 20:08:21 +0200 Subject: [PATCH] Fix startup crash in sideloaded builds without iCloud entitlements Sideloading tools re-sign the app with a team that cannot register the iCloud.stream.yattee.app container, and CKContainer(identifier:) fatally traps when the entitlement is missing. Detect availability by parsing the embedded provisioning profile, skip creating any CloudKit objects when the entitlement is absent, and show an explanation in iCloud settings instead of the sync toggle. --- Yattee/Localizable.xcstrings | 22 ++++++++ .../CloudKit/CloudKitAvailability.swift | 51 +++++++++++++++++++ .../CloudKit/CloudKitSyncEngine.swift | 51 ++++++++++++++----- .../Views/Settings/iCloudSettingsView.swift | 39 ++++++++------ 4 files changed, 135 insertions(+), 28 deletions(-) create mode 100644 Yattee/Services/CloudKit/CloudKitAvailability.swift 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)