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.
This commit is contained in:
Arkadiusz Fal
2026-07-27 20:08:21 +02:00
parent 7c07189023
commit 40f043b769
4 changed files with 135 additions and 28 deletions

View File

@@ -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" : { "settings.icloud.update.message" : {
"comment" : "Message explaining that newer app version is needed for some synced data", "comment" : "Message explaining that newer app version is needed for some synced data",
"localizations" : { "localizations" : {

View File

@@ -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("<?xml".utf8)),
let end = data.range(of: Data("</plist>".utf8), in: start.lowerBound..<data.endIndex),
let plist = try? PropertyListSerialization.propertyList(
from: data.subdata(in: start.lowerBound..<end.upperBound),
format: nil
),
let profile = plist as? [String: Any]
else { return nil }
return profile["Entitlements"] as? [String: Any]
}
}

View File

@@ -64,9 +64,14 @@ final class CloudKitSyncEngine: @unchecked Sendable {
// MARK: - State // MARK: - State
private let container: CKContainer /// Whether CloudKit is usable in this installation. False when the app was
private let database: CKDatabase /// re-signed without the iCloud container entitlement (sideloading); all
private let zoneManager: CloudKitZoneManager /// sync functionality is disabled then and the CloudKit objects stay nil.
let isCloudKitAvailable = CloudKitAvailability.isAvailable
private let container: CKContainer?
private let database: CKDatabase?
private let zoneManager: CloudKitZoneManager?
private var recordMapper: CloudKitRecordMapper private var recordMapper: CloudKitRecordMapper
private var conflictResolver: CloudKitConflictResolver private var conflictResolver: CloudKitConflictResolver
private var syncEngine: CKSyncEngine? private var syncEngine: CKSyncEngine?
@@ -232,12 +237,21 @@ final class CloudKitSyncEngine: @unchecked Sendable {
self.settingsManager = settingsManager self.settingsManager = settingsManager
self.instancesManager = instancesManager self.instancesManager = instancesManager
// Initialize CloudKit // Initialize CloudKit. CKContainer(identifier:) fatally traps when the
self.container = CKContainer(identifier: AppIdentifiers.iCloudContainer) // entitlement is missing (re-signed/sideloaded builds), so it must not
self.database = container.privateCloudDatabase // be constructed at all in that case.
if CloudKitAvailability.isAvailable {
// Initialize zone manager let container = CKContainer(identifier: AppIdentifiers.iCloudContainer)
self.zoneManager = CloudKitZoneManager(database: database) let database = container.privateCloudDatabase
self.container = container
self.database = database
self.zoneManager = CloudKitZoneManager(database: database)
} else {
LoggingService.shared.logCloudKit("iCloud container entitlement missing (re-signed build) - CloudKit sync unavailable")
self.container = nil
self.database = nil
self.zoneManager = nil
}
// Initialize with temporary zone (will be updated in setupSyncEngine) // Initialize with temporary zone (will be updated in setupSyncEngine)
let tempZone = RecordType.createZone() let tempZone = RecordType.createZone()
@@ -261,6 +275,10 @@ final class CloudKitSyncEngine: @unchecked Sendable {
/// Enables CloudKit sync. Creates CKSyncEngine and starts syncing. /// Enables CloudKit sync. Creates CKSyncEngine and starts syncing.
func enable() async { func enable() async {
guard isCloudKitAvailable else {
LoggingService.shared.logCloudKit("CloudKit unavailable in this installation, cannot enable sync")
return
}
guard syncEngine == nil else { guard syncEngine == nil else {
LoggingService.shared.logCloudKit("Sync engine already enabled") LoggingService.shared.logCloudKit("Sync engine already enabled")
return return
@@ -312,6 +330,11 @@ final class CloudKitSyncEngine: @unchecked Sendable {
return return
} }
guard let container, let database, let zoneManager else {
LoggingService.shared.logCloudKit("CloudKit unavailable in this installation, skipping engine setup")
return
}
do { do {
// Check iCloud account status // Check iCloud account status
let status = try await container.accountStatus() let status = try await container.accountStatus()
@@ -387,6 +410,7 @@ final class CloudKitSyncEngine: @unchecked Sendable {
/// Checks if the iCloud account has changed and handles it by clearing sync state. /// Checks if the iCloud account has changed and handles it by clearing sync state.
/// - Returns: `true` if account changed and sync state was cleared, `false` otherwise. /// - Returns: `true` if account changed and sync state was cleared, `false` otherwise.
private func checkAndHandleAccountChange() async -> Bool { private func checkAndHandleAccountChange() async -> Bool {
guard let container else { return false }
do { do {
// Fetch the current user's record ID using async wrapper // Fetch the current user's record ID using async wrapper
let currentUserRecordID = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<CKRecord.ID, Error>) in let currentUserRecordID = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<CKRecord.ID, Error>) in
@@ -1422,6 +1446,8 @@ final class CloudKitSyncEngine: @unchecked Sendable {
/// Clear all sync state and reset. For testing/debugging only. /// Clear all sync state and reset. For testing/debugging only.
func resetSync() async throws { func resetSync() async throws {
guard let zoneManager else { return }
// Delete zone (and all records) // Delete zone (and all records)
try await zoneManager.deleteZone() try await zoneManager.deleteZone()
@@ -1449,6 +1475,7 @@ final class CloudKitSyncEngine: @unchecked Sendable {
/// Refreshes the cached iCloud account status /// Refreshes the cached iCloud account status
func refreshAccountStatus() async { func refreshAccountStatus() async {
guard let container else { return }
do { do {
accountStatus = try await container.accountStatus() accountStatus = try await container.accountStatus()
} catch { } catch {
@@ -1566,7 +1593,7 @@ final class CloudKitSyncEngine: @unchecked Sendable {
case .zoneNotFound: case .zoneNotFound:
LoggingService.shared.logCloudKit("Zone not found, recreating...") LoggingService.shared.logCloudKit("Zone not found, recreating...")
Task { Task {
try? await zoneManager.createZoneIfNeeded() try? await zoneManager?.createZoneIfNeeded()
await sync() await sync()
} }
@@ -1882,7 +1909,7 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
// Zone was deleted recreate it and retry the save // Zone was deleted recreate it and retry the save
syncEngine?.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) syncEngine?.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
Task { Task {
try? await zoneManager.createZoneIfNeeded() try? await zoneManager?.createZoneIfNeeded()
} }
LoggingService.shared.logCloudKit("Zone missing for \(recordName), recreating and retrying") LoggingService.shared.logCloudKit("Zone missing for \(recordName), recreating and retrying")
@@ -1966,7 +1993,7 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
retryCount.removeAll() retryCount.removeAll()
do { do {
try await zoneManager.createZoneIfNeeded() try await zoneManager?.createZoneIfNeeded()
await performInitialUpload() await performInitialUpload()
} catch { } catch {
LoggingService.shared.logCloudKitError("Failed to recreate zone after remote deletion", error: error) LoggingService.shared.logCloudKitError("Failed to recreate zone after remote deletion", error: error)

View File

@@ -58,24 +58,31 @@ struct iCloudSettingsView: View {
} }
#endif #endif
SettingsFormSection(footer: "settings.icloud.footer") { if cloudKitSync?.isCloudKitAvailable == false {
Toggle(isOn: Binding( SettingsFormSection(footer: "settings.icloud.unavailable.footer") {
get: { settingsManager?.iCloudSyncEnabled ?? false }, Label(String(localized: "settings.icloud.unavailable"), systemImage: "icloud.slash")
set: { newValue in .foregroundStyle(.secondary)
if newValue { }
showingEnableConfirmation = true } else {
} else { SettingsFormSection(footer: "settings.icloud.footer") {
showingDisableConfirmation = true Toggle(isOn: Binding(
} get: { settingsManager?.iCloudSyncEnabled ?? false },
} set: { newValue in
)) { if newValue {
Label(String(localized: "settings.icloud.enable"), systemImage: "icloud") showingEnableConfirmation = true
} else {
showingDisableConfirmation = true
}
}
)) {
Label(String(localized: "settings.icloud.enable"), systemImage: "icloud")
}
} }
}
if settingsManager?.iCloudSyncEnabled == true { if settingsManager?.iCloudSyncEnabled == true {
syncCategoriesSection syncCategoriesSection
syncStatusSection syncStatusSection
}
} }
} }
#if !os(tvOS) #if !os(tvOS)