From 7eeebf2981b01f32ea568de4087202074713505e Mon Sep 17 00:00:00 2001 From: Lukas Kollmer Date: Sun, 27 Sep 2026 09:44:37 +0200 Subject: [PATCH] iOS 27 leftovers --- .../SecurityRelatedModifier.swift | 2 +- .../Model/BluetoothManagerStorage.swift | 8 +- .../LocalPreferences/LocalPreference.swift | 23 ++++- .../HealthKitSampleCollector.swift | 8 +- .../HKHealthStore+BackgroundDelivery.swift | 91 +++++++++++-------- .../Study Manager/StudyManager.swift | 74 +++++++-------- .../Text/MarkdownView+ImageProviders.swift | 2 +- 7 files changed, 123 insertions(+), 85 deletions(-) diff --git a/Sources/GroveAccount/AccountService/IdentityProvider/SecurityRelatedModifier.swift b/Sources/GroveAccount/AccountService/IdentityProvider/SecurityRelatedModifier.swift index 370eeca7f..c6dd33ddf 100644 --- a/Sources/GroveAccount/AccountService/IdentityProvider/SecurityRelatedModifier.swift +++ b/Sources/GroveAccount/AccountService/IdentityProvider/SecurityRelatedModifier.swift @@ -75,7 +75,7 @@ public struct SecurityRelatedModifier { /// Create a new security related modifier declaration. /// - Parameter wrappedValue: An auto-closure creating a new security related modifier each time it is used. - public init(wrappedValue: @autoclosure @escaping @Sendable () -> V) { + public init(wrappedValue: @autoclosure @escaping @MainActor @Sendable () -> V) { self.modifierClosure = { @MainActor in wrappedValue() } diff --git a/Sources/GroveBluetooth/CoreBluetooth/Model/BluetoothManagerStorage.swift b/Sources/GroveBluetooth/CoreBluetooth/Model/BluetoothManagerStorage.swift index a4fb725e2..84036da6f 100644 --- a/Sources/GroveBluetooth/CoreBluetooth/Model/BluetoothManagerStorage.swift +++ b/Sources/GroveBluetooth/CoreBluetooth/Model/BluetoothManagerStorage.swift @@ -165,8 +165,12 @@ final class BluetoothManagerStorage: ValueObservable, Sendable { extension BluetoothManagerStorage { var stateSubscription: AsyncStream { AsyncStream(BluetoothState.self) { continuation in - Task { @GroveBluetooth [self] in - let id = subscribe(continuation) + Task { @GroveBluetooth [weak self] in + guard let self else { + continuation.finish() + return + } + let id = self.subscribe(continuation) continuation.onTermination = { @Sendable [weak self] _ in guard let self = self else { return diff --git a/Sources/GroveFoundation/LocalPreferences/LocalPreference.swift b/Sources/GroveFoundation/LocalPreferences/LocalPreference.swift index 8e484d50e..503f06038 100644 --- a/Sources/GroveFoundation/LocalPreferences/LocalPreference.swift +++ b/Sources/GroveFoundation/LocalPreferences/LocalPreference.swift @@ -66,11 +66,27 @@ import Synchronization @available(iOS 18, macOS 15, watchOS 11, *) @propertyWrapper public struct LocalPreference: DynamicProperty, Sendable { + private enum UsageContext { + case swiftUI, external + } + private let key: LocalPreferenceKey private let store: LocalPreferencesStore - @State private var observer = UserDefaultsKeyObserver() + private var usageContext: UsageContext = .external + + private let externalObserver = UserDefaultsKeyObserver() + @State private var swiftUIObserver = UserDefaultsKeyObserver() + + private var observer: UserDefaultsKeyObserver { + switch usageContext { + case .swiftUI: + swiftUIObserver + case .external: + externalObserver + } + } - /// The current value of the local preference.. + /// The current value of the local preference. public var wrappedValue: T { get { _ = observer.viewUpdate @@ -103,7 +119,8 @@ public struct LocalPreference: DynamicProperty, Sendable { } @_documentation(visibility: internal) - nonisolated public func update() { + nonisolated public mutating func update() { + usageContext = .swiftUI observer.configure(for: key, in: store) } } diff --git a/Sources/GroveHealthKit/Health Data Collection/HealthKitSampleCollector.swift b/Sources/GroveHealthKit/Health Data Collection/HealthKitSampleCollector.swift index 55f1ce3f6..0d894eaa9 100644 --- a/Sources/GroveHealthKit/Health Data Collection/HealthKitSampleCollector.swift +++ b/Sources/GroveHealthKit/Health Data Collection/HealthKitSampleCollector.swift @@ -80,7 +80,7 @@ final class HealthKitSampleCollector: HealthDat do { if deliverySetting.continueInBackground { // set up a background query - let queryInvalidator = try await healthStore.startBackgroundDelivery(for: [sampleType.hkSampleType]) { [weak self] result in + let queryInvalidator = try await healthStore.startBackgroundDelivery(for: sampleType.hkSampleType) { [weak self] result in guard let self, self.isActive else { // if the sample collector has been turned off, we don't want to process these. return @@ -135,7 +135,11 @@ final class HealthKitSampleCollector: HealthDat task.cancel() case .backgroundDelivery(let invalidator): invalidator.invalidate() - healthStore.disableBackgroundDelivery(for: [sampleType.hkSampleType]) + do { + try await healthStore.disableBackgroundDelivery(for: sampleType.hkSampleType) + } catch { + healthKit.logger.error("Failed to stop data collection for sample collector for \(self.sampleType.id): \(error)") + } } } diff --git a/Sources/GroveHealthKit/HealthKit Extensions/HKHealthStore+BackgroundDelivery.swift b/Sources/GroveHealthKit/HealthKit Extensions/HKHealthStore+BackgroundDelivery.swift index 07235204f..337c76213 100644 --- a/Sources/GroveHealthKit/HealthKit Extensions/HKHealthStore+BackgroundDelivery.swift +++ b/Sources/GroveHealthKit/HealthKit Extensions/HKHealthStore+BackgroundDelivery.swift @@ -8,7 +8,7 @@ #if canImport(HealthKit) -import Grove +import class GroveFoundation.AsyncSemaphore import HealthKit import OSLog @@ -31,19 +31,17 @@ extension HKHealthStore { } } - private static let activeObservationsLock = NSLock() - nonisolated(unsafe) private static var activeObservations: [HKObjectType: Int] = [:] - - @MainActor @discardableResult + @MainActor + @discardableResult func startBackgroundDelivery( - for sampleTypes: Set, + for sampleType: HKSampleType, withPredicate predicate: NSPredicate? = nil, updateHandler: @escaping @MainActor @Sendable ( Result<(sampleTypes: Set, completionHandler: HKObserverQueryCompletionHandler), any Error> ) async -> Void ) async throws -> BackgroundObserverQueryInvalidator { - let queryDescriptors: [HKQueryDescriptor] = sampleTypes - .flatMap { $0.effectiveObjectTypesForAuthorization } + let queryDescriptors: [HKQueryDescriptor] = sampleType + .effectiveObjectTypesForAuthorization .compactMap { $0 as? HKSampleType } .map { HKQueryDescriptor(sampleType: $0, predicate: predicate) } let observerQuery = HKObserverQuery(queryDescriptors: queryDescriptors) { query, sampleTypes, completionHandler, error in @@ -75,48 +73,61 @@ extension HKHealthStore { await updateHandler(.success((sampleTypes, completionHandler))) } } + do { + var enabled = Set() + do { + for descriptor in queryDescriptors { + try await enableBackgroundDelivery(for: descriptor.sampleType) + enabled.insert(descriptor.sampleType) + } + } catch { + // If one sample type fails to enable, roll back the whole operation as best we can. + for sampleType in enabled { + try? await disableBackgroundDelivery(for: sampleType) + } + throw error + } + } self.execute(observerQuery) - try await enableBackgroundDelivery(for: queryDescriptors.mapIntoSet(\.sampleType)) return .init(healthStore: self, query: observerQuery) } +} + + +@available(iOS 18, macOS 15, watchOS 11, *) +extension HKHealthStore { + private static let backgroundDeliveryOperationsGate = AsyncSemaphore() + nonisolated(unsafe) private static var backgroundObserverCounts: [HKObjectType: Int] = [:] - func enableBackgroundDelivery(for objectTypes: Set) async throws { - var enabledObjectTypes: Set = [] - do { - for objectType in objectTypes { - try await self.enableBackgroundDelivery(for: objectType, frequency: .immediate) - enabledObjectTypes.insert(objectType) - Self.activeObservationsLock.withLock { - Self.activeObservations[objectType, default: 0] += 1 - } - } - } catch { - HealthKit.logger.error("Could not enable HealthKit Backgound access for \(objectTypes): \(error.localizedDescription)") - // Revert all changes as enable background delivery for the object types failed. - disableBackgroundDelivery(for: enabledObjectTypes) + func enableBackgroundDelivery(for objectType: HKObjectType) async throws { + await Self.backgroundDeliveryOperationsGate.wait() + defer { + Self.backgroundDeliveryOperationsGate.signal() + } + if Self.backgroundObserverCounts[objectType, default: 0] > 0 { + // Already registered, so we only need to increment the count. + Self.backgroundObserverCounts[objectType, default: 0] += 1 + return } + try await self.HealthKit::enableBackgroundDelivery(for: objectType, frequency: .immediate) + Self.backgroundObserverCounts[objectType] = 1 } - func disableBackgroundDelivery( - for objectTypes: Set - ) { - Self.activeObservationsLock.withLock { - for objectType in objectTypes { - if let activeObservation = HKHealthStore.activeObservations[objectType] { - let newActiveObservation = activeObservation - 1 - if newActiveObservation <= 0 { - Self.activeObservations[objectType] = nil - Task { @MainActor in - try? await self.disableBackgroundDelivery(for: objectType) - } - } else { - Self.activeObservations[objectType] = newActiveObservation - } - } - } + func disableBackgroundDelivery(for objectType: HKObjectType) async throws { + await Self.backgroundDeliveryOperationsGate.wait() + defer { + Self.backgroundDeliveryOperationsGate.signal() + } + let numActiveObservers = HKHealthStore.backgroundObserverCounts[objectType, default: 0] + guard numActiveObservers > 0 else { + return + } + if numActiveObservers == 1 { + try await self.HealthKit::disableBackgroundDelivery(for: objectType) } + HKHealthStore.backgroundObserverCounts[objectType] = numActiveObservers - 1 } } diff --git a/Sources/GroveStudy/Study Manager/StudyManager.swift b/Sources/GroveStudy/Study Manager/StudyManager.swift index 57b473489..9587d2d5c 100644 --- a/Sources/GroveStudy/Study Manager/StudyManager.swift +++ b/Sources/GroveStudy/Study Manager/StudyManager.swift @@ -142,8 +142,8 @@ public final class StudyManager: Module, EnvironmentAccessible, Sendable { /// Runs the identifier rewrite before anything reads the scheduler store. /// - /// Synchronous and outside `configure()`'s `Task` block on purpose: that block's `catch` returns - /// without logging, and the rewrite has to finish before tasks are registered against it. + /// Synchronous and outside `configure()`'s `Task` block on purpose: the rewrite has to finish + /// before tasks are registered against it. private func migrateLegacyTaskIdentifiersIfNeeded() { do { if try migrateLegacyTaskIdentifiers() { @@ -159,47 +159,50 @@ public final class StudyManager: Module, EnvironmentAccessible, Sendable { @_documentation(visibility: internal) - public func configure() { // swiftlint:disable:this function_body_length + public func configure() { migrateLegacyTaskIdentifiersIfNeeded() - typealias Task = Swift::Task - Task { @MainActor [self] in - let enrollments: [StudyEnrollment] + Swift::Task { @MainActor in do { - enrollments = try modelContext.fetch(FetchDescriptor()) - try registerStudyTasksWithScheduler(for: enrollments) - try await setupStudyBackgroundComponents(for: enrollments) - try removeOrphanedTasks() - try removeOrphanedStudyBundles() + try await _configure() } catch { - return + logger.error("configure failed: \(error)") } - #if targetEnvironment(simulator) - if autosaveTask == nil { - autosaveTask = Task.detached { - while true { - await MainActor.run { - try? self.modelContext.save() - } - try? await Task.sleep(for: .seconds(0.25)) + } + } + + + @MainActor + private func _configure() async throws { // swiftlint:disable:this function_body_length + let enrollments = try modelContext.fetch(FetchDescriptor()) + try registerStudyTasksWithScheduler(for: enrollments) + try await setupStudyBackgroundComponents(for: enrollments) + try removeOrphanedTasks() + try removeOrphanedStudyBundles() + #if targetEnvironment(simulator) + if autosaveTask == nil { + autosaveTask = Swift::Task { + while true { + await MainActor.run { + try? self.modelContext.save() } + try? await Swift::Task.sleep(for: .seconds(0.25)) } } - #endif - outcomesObserverToken = scheduler.observeNewOutcomes { [weak self] outcome in - guard let self, - let studyContext = outcome.task.studyContext, - let studyBundle = self.studyEnrollments.first(where: { $0.studyId == studyContext.studyId })?.studyBundle else { - return - } - self.handleStudyLifecycleEvent( - .completedTask(componentId: studyContext.componentId), - for: studyBundle, - at: .now - ) + } + #endif + outcomesObserverToken = scheduler.observeNewOutcomes { [weak self = self] outcome in + guard let self, + let studyContext = outcome.task.studyContext, + let studyBundle = self.studyEnrollments.first(where: { $0.studyId == studyContext.studyId })?.studyBundle else { + return } + self.handleStudyLifecycleEvent( + .completedTask(componentId: studyContext.componentId), + for: studyBundle, + at: .now + ) } - - Task { [weak self] in + Swift::Task { [weak self] in let localeUpdates = NotificationCenter.default.notifications(named: NSLocale.currentLocaleDidChangeNotification) for await _ in localeUpdates { guard let self else { @@ -211,7 +214,7 @@ public final class StudyManager: Module, EnvironmentAccessible, Sendable { } } #if canImport(UIKit) && !os(watchOS) - Task { [weak self] in + Swift::Task { [weak self] in let timeUpdates = NotificationCenter.default.notifications(named: UIApplication.significantTimeChangeNotification) for await _ in timeUpdates { guard let self else { @@ -221,7 +224,6 @@ public final class StudyManager: Module, EnvironmentAccessible, Sendable { } } #endif - do { try fixTaskContextAndDuplicateVersions() } catch { diff --git a/Sources/GroveViews/Views/Text/MarkdownView+ImageProviders.swift b/Sources/GroveViews/Views/Text/MarkdownView+ImageProviders.swift index 094fa53ae..ad7997268 100644 --- a/Sources/GroveViews/Views/Text/MarkdownView+ImageProviders.swift +++ b/Sources/GroveViews/Views/Text/MarkdownView+ImageProviders.swift @@ -14,7 +14,7 @@ import SwiftUI @available(iOS 18, macOS 15, watchOS 11, *) struct MarkdownViewImageProvider: ImageProvider { private struct ImageLoadingView: View { - let url: URL? + private let url: URL? @State private var image: Image? var body: some View {