From 3de8dcc28140cc9c0e1dd19e8a05a6528f6c91b8 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Sun, 23 Jun 2024 20:11:47 +0200 Subject: [PATCH 1/8] Allow external ownership for Modules --- Package.swift | 22 +- .../CollectPropertyWrapper.swift | 4 +- .../Communication/CollectedModuleValues.swift | 66 +---- .../ProvidePropertyWrapper.swift | 47 ++- .../Observable/ModelPropertyWrapper.swift | 13 + .../ModifierPropertyWrapper.swift | 12 + .../ViewModifier/ViewModifierProvider.swift | 11 +- .../Property/DependencyCollection.swift | 4 +- .../Property/DependencyContext.swift | 49 +++- .../Property/DependencyDeclaration.swift | 2 +- .../Property/DependencyPropertyWrapper.swift | 21 +- Sources/Spezi/Module/ModuleOwnership.swift | 23 ++ Sources/Spezi/Spezi/Spezi+Spezi.swift | 27 ++ Sources/Spezi/Spezi/Spezi.swift | 274 ++++++++++++------ .../DependenciesTests/DependencyTests.swift | 68 ++++- 15 files changed, 467 insertions(+), 176 deletions(-) create mode 100644 Sources/Spezi/Module/ModuleOwnership.swift create mode 100644 Sources/Spezi/Spezi/Spezi+Spezi.swift diff --git a/Package.swift b/Package.swift index 4dd409a3..a872007e 100644 --- a/Package.swift +++ b/Package.swift @@ -27,21 +27,26 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/StanfordSpezi/SpeziFoundation", from: "1.0.2"), - .package(url: "https://github.com/StanfordBDHG/XCTRuntimeAssertions", from: "1.0.1") + .package(url: "https://github.com/StanfordBDHG/XCTRuntimeAssertions", from: "1.0.1"), + .package(url: "https://github.com/apple/swift-collections.git", from: "1.1.1"), + .package(url: "https://github.com/realm/SwiftLint.git", .upToNextMinor(from: "0.55.1")) ], targets: [ .target( name: "Spezi", dependencies: [ .product(name: "SpeziFoundation", package: "SpeziFoundation"), - .product(name: "XCTRuntimeAssertions", package: "XCTRuntimeAssertions") - ] + .product(name: "XCTRuntimeAssertions", package: "XCTRuntimeAssertions"), + .product(name: "OrderedCollections", package: "swift-collections") + ], + plugins: [.swiftLintPlugin] ), .target( name: "XCTSpezi", dependencies: [ .target(name: "Spezi") - ] + ], + plugins: [.swiftLintPlugin] ), .testTarget( name: "SpeziTests", @@ -49,7 +54,14 @@ let package = Package( .target(name: "Spezi"), .target(name: "XCTSpezi"), .product(name: "XCTRuntimeAssertions", package: "XCTRuntimeAssertions") - ] + ], + plugins: [.swiftLintPlugin] ) ] ) + +extension Target.PluginUsage { + static var swiftLintPlugin: Target.PluginUsage { + .plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLint") + } +} diff --git a/Sources/Spezi/Capabilities/Communication/CollectPropertyWrapper.swift b/Sources/Spezi/Capabilities/Communication/CollectPropertyWrapper.swift index f113198f..09888ba3 100644 --- a/Sources/Spezi/Capabilities/Communication/CollectPropertyWrapper.swift +++ b/Sources/Spezi/Capabilities/Communication/CollectPropertyWrapper.swift @@ -40,7 +40,9 @@ public class _CollectPropertyWrapper { extension _CollectPropertyWrapper: StorageValueCollector { public func retrieve>(from repository: Repository) { - injectedValues = repository[CollectedModuleValues.self]?.map { $0.value } ?? [] + injectedValues = repository[CollectedModuleValues.self].reduce(into: []) { partialResult, entry in + partialResult.append(contentsOf: entry.value) + } } func clear() { diff --git a/Sources/Spezi/Capabilities/Communication/CollectedModuleValues.swift b/Sources/Spezi/Capabilities/Communication/CollectedModuleValues.swift index 93cdbb2f..e0ee97db 100644 --- a/Sources/Spezi/Capabilities/Communication/CollectedModuleValues.swift +++ b/Sources/Spezi/Capabilities/Communication/CollectedModuleValues.swift @@ -6,74 +6,16 @@ // SPDX-License-Identifier: MIT // +import Foundation +import OrderedCollections import SpeziFoundation -protocol AnyCollectModuleValue { - associatedtype Value - var moduleReference: ModuleReference { get } -} - -protocol AnyCollectModuleValues { - associatedtype Value - - var values: [any AnyCollectModuleValue] { get } - - mutating func removeValues(from module: any Module) -> Bool - - func store(into storage: inout SpeziStorage) -} - - -struct CollectModuleValue: AnyCollectModuleValue { - let value: Value - let moduleReference: ModuleReference - - init(_ value: Value) { - self.value = value - - guard let module = Spezi.moduleInitContext else { - preconditionFailure("Tried to initialize CollectModuleValue with unknown module init context.") - } - self.moduleReference = ModuleReference(module) - } -} - -/// Provides the ``KnowledgeSource`` for any value we store in the ``SpeziStorage`` that is -/// provided or request from am ``Module``. -/// -/// For more information, look at the ``Module/Provide`` or ``Module/Collect`` property wrappers. struct CollectedModuleValues: DefaultProvidingKnowledgeSource { typealias Anchor = SpeziAnchor - - typealias Value = [CollectModuleValue] - + typealias Value = OrderedDictionary static var defaultValue: Value { - [] - } -} - - -extension Array: AnyCollectModuleValues where Element: AnyCollectModuleValue { - typealias Value = Element.Value - - var values: [any AnyCollectModuleValue] { - self - } - - mutating func removeValues(from module: any Module) -> Bool { - let previousCount = count - removeAll { entry in - entry.moduleReference == ModuleReference(module) - } - return previousCount != count - } - - func store(into storage: inout SpeziStorage) { - guard let values = self as? [CollectModuleValue] else { - preconditionFailure("Unexpected array type: \(type(of: self))") - } - storage[CollectedModuleValues.self] = values + [:] } } diff --git a/Sources/Spezi/Capabilities/Communication/ProvidePropertyWrapper.swift b/Sources/Spezi/Capabilities/Communication/ProvidePropertyWrapper.swift index 1af02946..18ab6e07 100644 --- a/Sources/Spezi/Capabilities/Communication/ProvidePropertyWrapper.swift +++ b/Sources/Spezi/Capabilities/Communication/ProvidePropertyWrapper.swift @@ -6,6 +6,8 @@ // SPDX-License-Identifier: MIT // + +import Foundation import SpeziFoundation import XCTRuntimeAssertions @@ -13,12 +15,16 @@ import XCTRuntimeAssertions /// A protocol that identifies a ``_ProvidePropertyWrapper`` which `Value` type is a `Collection`. protocol CollectionBasedProvideProperty { func collectArrayElements>(into repository: inout Repository) + + func clearValues() } /// A protocol that identifies a ``_ProvidePropertyWrapper`` which `Value` type is a `Optional`. protocol OptionalBasedProvideProperty { func collectOptional>(into repository: inout Repository) + + func clearValues() } @@ -28,10 +34,16 @@ public class _ProvidePropertyWrapper { // swiftlint:disable:previous type_name // We want the type to be hidden from autocompletion and documentation generation + /// Persistent identifier to store and remove @Provide values. + fileprivate let id = UUID() + private var storedValue: Value private var collected = false + private weak var spezi: Spezi? + + /// Access the store value. /// - Note: You cannot access the value once it was collected. public var wrappedValue: Value { @@ -50,6 +62,15 @@ public class _ProvidePropertyWrapper { public init(wrappedValue value: Value) { self.storedValue = value } + + func inject(spezi: Spezi) { + self.spezi = spezi + } + + + deinit { + clear() + } } @@ -116,7 +137,7 @@ extension _ProvidePropertyWrapper: StorageValueProvider { } else if let wrapperWithArray = self as? CollectionBasedProvideProperty { wrapperWithArray.collectArrayElements(into: &repository) } else { - repository.appendValues([CollectModuleValue(storedValue)]) + repository.setValues(for: id, [storedValue]) } collected = true @@ -124,13 +145,25 @@ extension _ProvidePropertyWrapper: StorageValueProvider { func clear() { collected = false + + if let wrapperWithOptional = self as? OptionalBasedProvideProperty { + wrapperWithOptional.clearValues() + } else if let wrapperWithArray = self as? CollectionBasedProvideProperty { + wrapperWithArray.clearValues() + } else { + spezi?.handleCollectedValueRemoval(for: id, of: Value.self) + } } } extension _ProvidePropertyWrapper: CollectionBasedProvideProperty where Value: AnyArray { func collectArrayElements>(into repository: inout Repository) { - repository.appendValues(storedValue.unwrappedArray.map { CollectModuleValue($0) }) + repository.setValues(for: id, storedValue.unwrappedArray) + } + + func clearValues() { + spezi?.handleCollectedValueRemoval(for: id, of: Value.Element.self) } } @@ -138,16 +171,20 @@ extension _ProvidePropertyWrapper: CollectionBasedProvideProperty where Value: A extension _ProvidePropertyWrapper: OptionalBasedProvideProperty where Value: AnyOptional { func collectOptional>(into repository: inout Repository) { if let storedValue = storedValue.unwrappedOptional { - repository.appendValues([CollectModuleValue(storedValue)]) + repository.setValues(for: id, [storedValue]) } } + + func clearValues() { + spezi?.handleCollectedValueRemoval(for: id, of: Value.Wrapped.self) + } } extension SharedRepository where Anchor == SpeziAnchor { - fileprivate mutating func appendValues(_ values: [CollectModuleValue]) { + fileprivate mutating func setValues(for id: UUID, _ values: [Value]) { var current = self[CollectedModuleValues.self] - current.append(contentsOf: values) + current[id] = values self[CollectedModuleValues.self] = current } } diff --git a/Sources/Spezi/Capabilities/Observable/ModelPropertyWrapper.swift b/Sources/Spezi/Capabilities/Observable/ModelPropertyWrapper.swift index 4f72c6e1..255b91c1 100644 --- a/Sources/Spezi/Capabilities/Observable/ModelPropertyWrapper.swift +++ b/Sources/Spezi/Capabilities/Observable/ModelPropertyWrapper.swift @@ -15,9 +15,12 @@ public class _ModelPropertyWrapper { // swiftlint:disable:previous type_name // We want the type to be hidden from autocompletion and documentation generation + let id = UUID() private var storedValue: Model? private var collected = false + private weak var spezi: Spezi? + /// Access the store model. /// @@ -44,12 +47,22 @@ public class _ModelPropertyWrapper { public init(wrappedValue: Model) { self.storedValue = wrappedValue } + + + deinit { + clear() + } } extension _ModelPropertyWrapper: SpeziPropertyWrapper { func clear() { collected = false + spezi?.handleViewModifierRemoval(for: id) + } + + func inject(spezi: Spezi) { + self.spezi = spezi } } diff --git a/Sources/Spezi/Capabilities/ViewModifier/ModifierPropertyWrapper.swift b/Sources/Spezi/Capabilities/ViewModifier/ModifierPropertyWrapper.swift index b32e69ed..c67d5e90 100644 --- a/Sources/Spezi/Capabilities/ViewModifier/ModifierPropertyWrapper.swift +++ b/Sources/Spezi/Capabilities/ViewModifier/ModifierPropertyWrapper.swift @@ -15,9 +15,12 @@ public class _ModifierPropertyWrapper { // swiftlint:disable:previous type_name // We want the type to be hidden from autocompletion and documentation generation + let id = UUID() private var storedValue: Modifier? private var collected = false + private weak var spezi: Spezi? + /// Access the store value. /// - Note: You cannot access the value once it was collected. @@ -43,12 +46,21 @@ public class _ModifierPropertyWrapper { public init(wrappedValue: Modifier) { self.storedValue = wrappedValue } + + deinit { + clear() + } } extension _ModifierPropertyWrapper: SpeziPropertyWrapper { func clear() { collected = false + spezi?.handleViewModifierRemoval(for: id) + } + + func inject(spezi: Spezi) { + self.spezi = spezi } } diff --git a/Sources/Spezi/Capabilities/ViewModifier/ViewModifierProvider.swift b/Sources/Spezi/Capabilities/ViewModifier/ViewModifierProvider.swift index ffece94b..d3de6208 100644 --- a/Sources/Spezi/Capabilities/ViewModifier/ViewModifierProvider.swift +++ b/Sources/Spezi/Capabilities/ViewModifier/ViewModifierProvider.swift @@ -6,6 +6,7 @@ // SPDX-License-Identifier: MIT // +import OrderedCollections import SwiftUI enum ModifierPlacement: Int, Comparable { @@ -21,6 +22,9 @@ enum ModifierPlacement: Int, Comparable { /// An adopter of this protocol is a property of a ``Module`` that provides a SwiftUI /// [`ViewModifier`](https://developer.apple.com/documentation/swiftui/viewmodifier) to be injected into the global view hierarchy. protocol ViewModifierProvider { + /// The persistent identifier for the view modifier provider. + var id: UUID { get } + /// The view modifier instance that should be injected into the SwiftUI view hierarchy. /// /// Does nothing if `nil` is provided. @@ -44,13 +48,16 @@ extension ViewModifierProvider { extension Module { /// All SwiftUI `ViewModifier` the module wants to modify the global view hierarchy with. - var viewModifiers: [any SwiftUI.ViewModifier] { + var viewModifierEntires: [(UUID, any SwiftUI.ViewModifier)] { retrieveProperties(ofType: ViewModifierProvider.self) .sorted { lhs, rhs in lhs.placement < rhs.placement } .compactMap { provider in - provider.viewModifier + guard let modifier = provider.viewModifier else { + return nil + } + return (provider.id, modifier) } } } diff --git a/Sources/Spezi/Dependencies/Property/DependencyCollection.swift b/Sources/Spezi/Dependencies/Property/DependencyCollection.swift index a7ab8261..24bde4b2 100644 --- a/Sources/Spezi/Dependencies/Property/DependencyCollection.swift +++ b/Sources/Spezi/Dependencies/Property/DependencyCollection.swift @@ -75,9 +75,9 @@ public struct DependencyCollection: DependencyDeclaration { } } - func uninjectDependencies() { + func uninjectDependencies(notifying spezi: Spezi) { for entry in entries { - entry.uninjectDependencies() + entry.uninjectDependencies(notifying: spezi) } } diff --git a/Sources/Spezi/Dependencies/Property/DependencyContext.swift b/Sources/Spezi/Dependencies/Property/DependencyContext.swift index ae19e52f..54a5bc36 100644 --- a/Sources/Spezi/Dependencies/Property/DependencyContext.swift +++ b/Sources/Spezi/Dependencies/Property/DependencyContext.swift @@ -17,8 +17,22 @@ protocol AnyDependencyContext: DependencyDeclaration { class DependencyContext: AnyDependencyContext { + private enum Storage { + case dependency(Dependency) + case weakDependency(WeaklyStoredModule) + + var value: Dependency? { + switch self { + case let .dependency(module): + return module + case let .weakDependency(reference): + return reference.module + } + } + } + let defaultValue: (() -> Dependency)? - private var injectedDependency: Dependency? + private var injectedDependency: Storage? var isOptional: Bool { @@ -26,7 +40,16 @@ class DependencyContext: AnyDependencyContext { } var injectedDependencies: [any Module] { - injectedDependency.map { [$0] } ?? [] + guard let injectedDependency else { + return [] + } + + guard let module = injectedDependency.value else { + self.injectedDependency = nil // clear the left over storage + return [] + } + + return [module] } init(for type: Dependency.Type = Dependency.self, defaultValue: (() -> Dependency)? = nil) { @@ -52,11 +75,25 @@ class DependencyContext: AnyDependencyContext { } func inject(from dependencyManager: DependencyManager) { - injectedDependency = dependencyManager.retrieve(optional: isOptional) + guard let dependency = dependencyManager.retrieve(module: Dependency.self, optional: isOptional) else { + injectedDependency = nil + return + } + + if isOptional { + injectedDependency = .weakDependency(WeaklyStoredModule(dependency)) + } else { + injectedDependency = .dependency(dependency) + } } - func uninjectDependencies() { + func uninjectDependencies(notifying spezi: Spezi) { + let dependency = injectedDependency?.value injectedDependency = nil + + if let dependency { + spezi.handleDependencyUninjection(dependency) + } } func retrieve(dependency: M.Type) -> M { @@ -68,14 +105,14 @@ class DependencyContext: AnyDependencyContext { """ ) } - guard let dependency = injectedDependency as? M else { + guard let dependency = injectedDependency.value as? M else { preconditionFailure("A injected dependency of type \(type(of: injectedDependency)) didn't match the expected type \(M.self)!") } return dependency } func retrieveOptional(dependency: M.Type) -> M? { - guard let dependency = injectedDependency as? M? else { + guard let dependency = injectedDependency?.value as? M? else { preconditionFailure("A injected dependency of type \(type(of: injectedDependency)) didn't match the expected type \(M?.self)!") } return dependency diff --git a/Sources/Spezi/Dependencies/Property/DependencyDeclaration.swift b/Sources/Spezi/Dependencies/Property/DependencyDeclaration.swift index d546b0e8..2305d026 100644 --- a/Sources/Spezi/Dependencies/Property/DependencyDeclaration.swift +++ b/Sources/Spezi/Dependencies/Property/DependencyDeclaration.swift @@ -30,7 +30,7 @@ protocol DependencyDeclaration { func inject(from dependencyManager: DependencyManager) /// Remove all dependency injections. - func uninjectDependencies() + func uninjectDependencies(notifying spezi: Spezi) /// Determine the dependency relationship to a given module. /// - Parameter module: The module to retrieve the dependency relationship for. diff --git a/Sources/Spezi/Dependencies/Property/DependencyPropertyWrapper.swift b/Sources/Spezi/Dependencies/Property/DependencyPropertyWrapper.swift index 5a223521..c0f53a28 100644 --- a/Sources/Spezi/Dependencies/Property/DependencyPropertyWrapper.swift +++ b/Sources/Spezi/Dependencies/Property/DependencyPropertyWrapper.swift @@ -31,6 +31,7 @@ private protocol ModuleArrayDependency { /// Refer to the documentation of ``Module/Dependency`` for information on how to use the `@Dependency` property wrapper. @propertyWrapper public class _DependencyPropertyWrapper { // swiftlint:disable:this type_name + private weak var spezi: Spezi? private let dependencies: DependencyCollection /// The dependency value. @@ -56,12 +57,26 @@ public class _DependencyPropertyWrapper { // swiftlint:disable:this type_ // this init is placed here directly, otherwise Swift has problems resolving this init self.init(wrappedValue: Value()) } + + deinit { + guard let spezi = spezi else { + return + } + uninjectDependencies(notifying: spezi) + } } extension _DependencyPropertyWrapper: SpeziPropertyWrapper { + func inject(spezi: Spezi) { + self.spezi = spezi + } + func clear() { - uninjectDependencies() + guard let spezi else { + preconditionFailure("\(Self.self) was clear without a Spezi instance available") + } + uninjectDependencies(notifying: spezi) } } @@ -84,8 +99,8 @@ extension _DependencyPropertyWrapper: DependencyDeclaration { dependencies.inject(from: dependencyManager) } - func uninjectDependencies() { - dependencies.uninjectDependencies() + func uninjectDependencies(notifying spezi: Spezi) { + dependencies.uninjectDependencies(notifying: spezi) } } diff --git a/Sources/Spezi/Module/ModuleOwnership.swift b/Sources/Spezi/Module/ModuleOwnership.swift new file mode 100644 index 00000000..667fe601 --- /dev/null +++ b/Sources/Spezi/Module/ModuleOwnership.swift @@ -0,0 +1,23 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + + +/// Determine the ownership policy when loading a `Module`. +public enum ModuleOwnership { + /// The Module is externally managed. + /// + /// Externally Modules are weakly referenced by Spezi and might deallocated at anytime. + /// If they were not required by any other modules, the deallocation of an externally Module will automatically result in corresponding resources to be deallocated. + /// + /// - Important: Externally-managed Module **cannot** conform to the ``EnvironmentAccessible`` protocol. + @_spi(APISupport) + case external + + /// The module is managed and strongly referenced by Spezi. + case spezi +} diff --git a/Sources/Spezi/Spezi/Spezi+Spezi.swift b/Sources/Spezi/Spezi/Spezi+Spezi.swift new file mode 100644 index 00000000..b8e9551a --- /dev/null +++ b/Sources/Spezi/Spezi/Spezi+Spezi.swift @@ -0,0 +1,27 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + + +extension Spezi { + /// Access the global Spezi instance. + /// + /// Access the global Spezi instance using the ``Module/Application`` property wrapper inside your ``Module``. + /// + /// Below is a short code example on how to access the Spezi instance. + /// + /// ```swift + /// class ExampleModule: Module { + /// @Application(\.spezi) + /// var spezi + /// } + /// ``` + public var spezi: Spezi { + // this seems nonsensical, but is essential to support Spezi access from the @Application modifier + self + } +} diff --git a/Sources/Spezi/Spezi/Spezi.swift b/Sources/Spezi/Spezi/Spezi.swift index fe059b28..32838ac9 100644 --- a/Sources/Spezi/Spezi/Spezi.swift +++ b/Sources/Spezi/Spezi/Spezi.swift @@ -7,6 +7,7 @@ // +import OrderedCollections import os import SpeziFoundation import SwiftUI @@ -28,6 +29,27 @@ private struct ImplicitlyCreatedModulesKey: DefaultProvidingKnowledgeSource { } +private protocol AnyWeaklyStoredModule { + var anyModule: (any Module)? { get } + + @discardableResult + func retrievePurgingIfNil>(in storage: inout Repository) -> (any Module)? +} + + +struct WeaklyStoredModule: KnowledgeSource { + typealias Anchor = SpeziAnchor + typealias Value = Self + + weak var module: M? + + + init(_ module: M) { + self.module = module + } +} + + /// Open-source framework for rapid development of modern, interoperable digital health applications. /// /// Set up the Spezi framework in your `App` instance of your SwiftUI application using the ``SpeziAppDelegate`` and the `@ApplicationDelegateAdaptor` property wrapper. @@ -103,19 +125,24 @@ public class Spezi { @TaskLocal static var moduleInitContext: (any Module)? let standard: any Standard + + /// Recursive lock for module loading. + private let lock = NSRecursiveLock() + /// A shared repository to store any `KnowledgeSource`s restricted to the ``SpeziAnchor``. /// /// Every `Module` automatically conforms to `KnowledgeSource` and is stored within this storage object. fileprivate(set) var storage: SpeziStorage - private var _viewModifiers: [ModuleReference: [any ViewModifier]] = [:] + /// Key is either a UUID for `@Modifier` or `@Model` property wrappers, or a `ModuleReference` for `EnvironmentAccessible` modifiers. + private var _viewModifiers: OrderedDictionary = [:] /// Array of all SwiftUI `ViewModifiers` collected using `_ModifierPropertyWrapper` from the configured ``Module``s. /// /// Any changes to this property will cause a complete re-render of the SwiftUI view hierarchy. See `SpeziViewModifier`. var viewModifiers: [any ViewModifier] { _viewModifiers.reduce(into: []) { partialResult, entry in - partialResult.append(contentsOf: entry.value) + partialResult.append(entry.value) } } @@ -143,8 +170,9 @@ public class Spezi { var modules: [any Module] { storage.collect(allOf: (any Module).self) + + storage.collect(allOf: (any AnyWeaklyStoredModule).self).compactMap { $0.retrievePurgingIfNil(in: &storage) } } - + private var implicitlyCreatedModules: Set { get { storage[ImplicitlyCreatedModulesKey.self] @@ -158,23 +186,6 @@ public class Spezi { } } - /// Access the global Spezi instance. - /// - /// Access the global Spezi instance using the ``Module/Application`` property wrapper inside your ``Module``. - /// - /// Below is a short code example on how to access the Spezi instance. - /// - /// ```swift - /// class ExampleModule: Module { - /// @Application(\.spezi) - /// var spezi - /// } - /// ``` - public var spezi: Spezi { - // this seems nonsensical, but is essential to support Spezi access from the @Application modifier - self - } - convenience init(from configuration: Configuration, storage: consuming SpeziStorage = SpeziStorage()) { self.init(standard: configuration.standard, modules: configuration.modules.elements, storage: storage) @@ -196,7 +207,7 @@ public class Spezi { self.standard = standard self.storage = consume storage - self.loadModules([self.standard] + modules) + self.loadModules([self.standard] + modules, ownership: .spezi) } /// Load a new Module. @@ -206,20 +217,38 @@ public class Spezi { /// /// - Important: While ``Module/Modifier`` and ``Module/Model`` properties and the ``EnvironmentAccessible`` protocol /// are generally supported with dynamically loaded Modules, they will cause the global SwiftUI view hierarchy to re-render. - /// This might be undesirable und will cause interruptions. Therefore, avoid dynamcially loading Modules with these properties. + /// This might be undesirable und will cause interruptions. Therefore, avoid dynamically loading Modules with these properties. /// - /// - Parameter module: The new Module instance to load. - public func loadModule(_ module: any Module) { - loadModules([module]) + /// - Parameters: + /// - module: The new Module instance to load. + /// - ownership: Define the type of ownership when loading the module. + /// + /// ## Topics + /// ### Ownership + /// - ``ModuleOwnership`` + public func loadModule(_ module: any Module, ownership: ModuleOwnership = .spezi) { + loadModules([module], ownership: ownership) } - private func loadModules(_ modules: [any Module]) { + private func loadModules(_ modules: [any Module], ownership: ModuleOwnership) { precondition(Self.moduleInitContext == nil, "Modules cannot be loaded within the `configure()` method.") + + lock.lock() + defer { + lock.unlock() + } + + purgeWeaklyReferenced() + + let requestedModules = Set(modules.map { ModuleReference($0) }) + logger.debug("Loading module(s) \(modules.map { "\(type(of: $0))" }.joined(separator: ", ")) ...") + + let existingModules = self.modules - + let dependencyManager = DependencyManager(modules, existing: existingModules) dependencyManager.resolve() - + implicitlyCreatedModules.formUnion(dependencyManager.implicitlyCreatedModules) // we pass through the whole list of modules once to collect all @Provide values @@ -230,7 +259,12 @@ public class Spezi { } for module in dependencyManager.initializedModules { - self.initModule(module) + if requestedModules.contains(ModuleReference(module)) { + // the policy only applies to the request modules, all other are always managed and owned by Spezi + self.initModule(module, ownership: ownership) + } else { + self.initModule(module, ownership: .spezi) + } } @@ -251,23 +285,36 @@ public class Spezi { /// - Parameter module: The Module to unload. public func unloadModule(_ module: any Module) { precondition(Self.moduleInitContext == nil, "Modules cannot be unloaded within the `configure()` method.") - + + lock.lock() + defer { + lock.unlock() + } + + purgeWeaklyReferenced() + guard module.isLoaded(in: self) else { return // module is not loaded } - - let dependents = retrieveDependingModules(module) + + logger.debug("Unloading module \(type(of: module)) ...") + + let dependents = retrieveDependingModules(module, considerOptionals: false) precondition(dependents.isEmpty, "Tried to unload Module \(type(of: module)) that is still required by peer Modules: \(dependents)") module.clearModule(from: self) implicitlyCreatedModules.remove(ModuleReference(module)) - - removeCollectValues(for: module) // this check is important. Change to viewModifiers re-renders the whole SwiftUI view hierarchy. So avoid to do it unnecessarily if _viewModifiers[ModuleReference(module)] != nil { - _viewModifiers[ModuleReference(module)] = nil + var keys: Set = [ModuleReference(module)] + keys.formUnion(module.viewModifierEntires.map { $0.0 }) + + // remove all at once + _viewModifiers.removeAll { entry in + keys.contains(entry.key) + } } // re-injecting all dependencies ensures that the unloaded module is cleared from optional Dependencies from @@ -275,50 +322,7 @@ public class Spezi { let dependencyManager = DependencyManager([], existing: modules) dependencyManager.resolve() - - // Check if we need to unload additional modules that were not explicitly created. - // For example a explicitly loaded Module might have recursive @Dependency declarations that are automatically loaded. - // Such modules are unloaded as well if they are no longer required. - for dependencyDeclaration in module.dependencyDeclarations { - let dependencies = dependencyDeclaration.injectedDependencies - for dependency in dependencies { - guard implicitlyCreatedModules.contains(ModuleReference(dependency)) else { - // we only recursively unload modules that have been created implicitly - continue - } - - guard retrieveDependingModules(dependency).isEmpty else { - continue - } - - unloadModule(dependency) - } - } - - module.clear() - } - - private func removeCollectValues(for module: any Module) { - let valueContainers = storage.collect(allOf: (any AnyCollectModuleValues).self) - - var changed = false - for var container in valueContainers { - let didChange = container.removeValues(from: module) - guard didChange else { - continue - } - - changed = true - container.store(into: &storage) - } - - guard changed else { - return - } - - for module in modules { - module.injectModuleValues(from: storage) - } + module.clear() // automatically removes @Provide values and recursively unloads implicitly created modules } /// Initialize a Module. @@ -327,7 +331,8 @@ public class Spezi { /// /// - Parameters: /// - module: The module to initialize. - private func initModule(_ module: any Module) { + /// - ownership: Define the type of ownership when loading the module. + private func initModule(_ module: any Module, ownership: ModuleOwnership) { precondition(!module.isLoaded(in: self), "Tried to initialize Module \(type(of: module)) that was already loaded!") Self.$moduleInitContext.withValue(module) { @@ -337,17 +342,27 @@ public class Spezi { module.injectModuleValues(from: storage) module.configure() - module.storeModule(into: self) - let viewModifiers = module.viewModifiers + switch ownership { + case .spezi: + module.storeModule(into: self) + case .external: + module.storeWeakly(into: self) + } + + let modifierEntires = module.viewModifierEntires // this check is important. Change to viewModifiers re-renders the whole SwiftUI view hierarchy. So avoid to do it unnecessarily - if !viewModifiers.isEmpty { - _viewModifiers[ModuleReference(module), default: []].append(contentsOf: module.viewModifiers) + if !modifierEntires.isEmpty { + _viewModifiers.merge(modifierEntires) { _, rhs in + rhs + } } // If a module is @Observable, we automatically inject it view the `ModelModifier` into the environment. if let observable = module as? EnvironmentAccessible { - _viewModifiers[ModuleReference(module), default: []].append(observable.viewModifier) + // we can't guarantee weak references for EnvironmentAccessible modules + precondition(ownership != .external, "Modules loaded with self-managed policy cannot conform to `EnvironmentAccessible`.") + _viewModifiers[ModuleReference(module)] = observable.viewModifier } } } @@ -357,7 +372,7 @@ public class Spezi { keyPath == \.logger // loggers are created per Module. } - private func retrieveDependingModules(_ dependency: any Module, considerOptionals: Bool = false) -> [any Module] { + private func retrieveDependingModules(_ dependency: any Module, considerOptionals: Bool) -> [any Module] { var result: [any Module] = [] for module in modules { @@ -375,6 +390,66 @@ public class Spezi { return result } + + func handleDependencyUninjection(_ dependency: M) { + lock.lock() + defer { + lock.unlock() + } + + guard implicitlyCreatedModules.contains(ModuleReference(dependency)) else { + // we only recursively unload modules that have been created implicitly + return + } + + guard retrieveDependingModules(dependency, considerOptionals: true).isEmpty else { + return + } + + unloadModule(dependency) + } + + func handleCollectedValueRemoval(for id: UUID, of type: Value.Type) { + lock.lock() + defer { + lock.unlock() + } + + var entries = storage[CollectedModuleValues.self] + let removed = entries.removeValue(forKey: id) + guard removed != nil else { + return + } + + storage[CollectedModuleValues.self] = entries + + for module in modules { + module.injectModuleValues(from: storage) + } + } + + func handleViewModifierRemoval(for id: UUID) { + lock.lock() + defer { + lock.unlock() + } + + if _viewModifiers[id] != nil { + _viewModifiers.removeValue(forKey: id) + } + } + + /// Iterates through weakly referenced modules and purges any nil references. + /// + /// These references are purged lazily. This is generally no problem because the overall overhead will be linear. + /// If you load `n` modules with self-managed storage policy and then all `n` modules will eventually be deallocated, there might be `n` weak references still stored + /// till the next module interaction is performed (e.g., new module is loaded or unloaded). + private func purgeWeaklyReferenced() { + let elements = storage.collect(allOf: (any AnyWeaklyStoredModule).self) + for reference in elements { + reference.retrievePurgingIfNil(in: &storage) + } + } } @@ -387,11 +462,38 @@ extension Module { spezi.storage[Self.self] = value } + fileprivate func storeWeakly(into spezi: Spezi) { + guard self is Value else { + spezi.logger.warning("Could not store \(Self.self) in the SpeziStorage as the `Value` typealias was modified.") + return + } + + spezi.storage[WeaklyStoredModule.self] = WeaklyStoredModule(self) + } + fileprivate func isLoaded(in spezi: Spezi) -> Bool { spezi.storage[Self.self] != nil + || spezi.storage[WeaklyStoredModule.self]?.retrievePurgingIfNil(in: &spezi.storage) != nil } fileprivate func clearModule(from spezi: Spezi) { spezi.storage[Self.self] = nil + spezi.storage[WeaklyStoredModule.self] = nil + } +} + + +extension WeaklyStoredModule: AnyWeaklyStoredModule { + var anyModule: (any Module)? { + module + } + + + func retrievePurgingIfNil>(in storage: inout Repository) -> (any Module)? { + guard let module else { + storage[Self.self] = nil + return nil + } + return module } } diff --git a/Tests/SpeziTests/DependenciesTests/DependencyTests.swift b/Tests/SpeziTests/DependenciesTests/DependencyTests.swift index c1591520..625ff13e 100644 --- a/Tests/SpeziTests/DependenciesTests/DependencyTests.swift +++ b/Tests/SpeziTests/DependenciesTests/DependencyTests.swift @@ -6,7 +6,7 @@ // SPDX-License-Identifier: MIT // -@_spi(Spezi) @testable import Spezi +@_spi(Spezi) @_spi(APISupport) @testable import Spezi import SwiftUI import XCTest import XCTRuntimeAssertions @@ -19,6 +19,8 @@ private final class TestModule1: Module { @Dependency var testModule3: TestModule3 @Provide var num: Int = 1 + @Provide var nums: [Int] = [9, 10] + @Provide var numsO: Int? = 11 init(deinitExpectation: XCTestExpectation = XCTestExpectation()) { self.deinitExpectation = deinitExpectation @@ -29,6 +31,14 @@ private final class TestModule1: Module { } } +private final class TestModuleX: Module { + @Provide var numX: Int + + init(_ num: Int) { + numX = num + } +} + private final class TestModule2: Module { @Dependency var testModule4 = TestModule4() @Dependency var testModule5 = TestModule5() @@ -122,8 +132,14 @@ private final class OptionalDependencyWithRuntimeDefault: Module { } } +private final class TestModule8: Module { + @Dependency var testModule1: TestModule1? + + init() {} +} + -final class DependencyTests: XCTestCase { +final class DependencyTests: XCTestCase { // swiftlint:disable:this type_body_length func testLoadingAdditionalDependency() throws { let spezi = Spezi(standard: DefaultStandard(), modules: [OptionalModuleDependency()]) @@ -206,7 +222,9 @@ final class DependencyTests: XCTestCase { XCTAssertEqual(optionalModule.nums, [3]) spezi.loadModule(module1) - XCTAssertEqual(optionalModule.nums, [3, 5, 4, 2, 1]) + XCTAssertEqual(optionalModule.nums, [3, 5, 4, 2, 1, 9, 10, 11]) + + XCTAssertEqual(spezi.modules.count, 7) spezi.unloadModule(module1) XCTAssertEqual(optionalModule.nums, [3]) @@ -251,6 +269,48 @@ final class DependencyTests: XCTestCase { wait(for: [deinitExpectation1, deinitExpectation3]) } + func testSelfManagedModules() throws { + let optionalModule = OptionalModuleDependency() + let moduleX = TestModuleX(5) + let module8 = TestModule8() + + func runModuleTests(deinitExpectation: XCTestExpectation) throws -> Spezi { + let module1 = TestModule1(deinitExpectation: deinitExpectation) + + let spezi = Spezi(standard: DefaultStandard(), modules: [optionalModule, moduleX, module8]) + + spezi.loadModule(module1, policy: .selfManaged) // LOAD AS SELFMANAGED + XCTAssertEqual(optionalModule.nums, [5, 5, 4, 3, 2, 1, 9, 10, 11]) + + // leaving this scope causes the module1 to deallocate and should automatically unload it from Spezi! + XCTAssertEqual(spezi.modules.count, 8) + return spezi + } + + let deinitExpectation = XCTestExpectation(description: "Deinit TestModule1") + + // make sure we keep the reference to `Spezi`, but loose all references to TestModule3 to test deinit getting called + let spezi = try runModuleTests(deinitExpectation: deinitExpectation) + _ = spezi + + print(spezi.modules) + XCTAssertEqual(spezi.modules.count, 5) + + XCTAssertNil(module8.testModule1) // tests that optional @Dependency reference modules weakly + + // While TestModule3 was loaded because of TestModule1, the OptionalModule still has a dependency to it. + // Therefore, they stay loaded. + XCTAssertNotNil(optionalModule.testModule3) + + XCTAssertEqual(optionalModule.nums, [5, 3]) + + + // TODO: test more! + // TODO: test viewModifers as well! + + wait(for: [deinitExpectation]) + } + func testModuleDependencyChain() throws { let modules: [any Module] = [ TestModule6(), @@ -447,3 +507,5 @@ final class DependencyTests: XCTestCase { XCTAssertEqual(dut4Module.state, 4) } } + +// swiftlint:disable:this file_length From cd396a65f21a59d660e100ae16bbf5855e4d2ea3 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Sun, 23 Jun 2024 21:34:29 +0200 Subject: [PATCH 2/8] Fix old code --- Tests/SpeziTests/DependenciesTests/DependencyTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/SpeziTests/DependenciesTests/DependencyTests.swift b/Tests/SpeziTests/DependenciesTests/DependencyTests.swift index 625ff13e..b81494da 100644 --- a/Tests/SpeziTests/DependenciesTests/DependencyTests.swift +++ b/Tests/SpeziTests/DependenciesTests/DependencyTests.swift @@ -279,7 +279,7 @@ final class DependencyTests: XCTestCase { // swiftlint:disable:this type_body_le let spezi = Spezi(standard: DefaultStandard(), modules: [optionalModule, moduleX, module8]) - spezi.loadModule(module1, policy: .selfManaged) // LOAD AS SELFMANAGED + spezi.loadModule(module1, ownership: .external) // LOAD AS EXTERNAL XCTAssertEqual(optionalModule.nums, [5, 5, 4, 3, 2, 1, 9, 10, 11]) // leaving this scope causes the module1 to deallocate and should automatically unload it from Spezi! From 443617800acc90feb8ccd86021791672c3e05b55 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Sun, 23 Jun 2024 22:13:31 +0200 Subject: [PATCH 3/8] Update count as weakly referenced are also returned --- Tests/SpeziTests/DependenciesTests/DependencyTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/SpeziTests/DependenciesTests/DependencyTests.swift b/Tests/SpeziTests/DependenciesTests/DependencyTests.swift index b81494da..e9ee0bbc 100644 --- a/Tests/SpeziTests/DependenciesTests/DependencyTests.swift +++ b/Tests/SpeziTests/DependenciesTests/DependencyTests.swift @@ -283,7 +283,7 @@ final class DependencyTests: XCTestCase { // swiftlint:disable:this type_body_le XCTAssertEqual(optionalModule.nums, [5, 5, 4, 3, 2, 1, 9, 10, 11]) // leaving this scope causes the module1 to deallocate and should automatically unload it from Spezi! - XCTAssertEqual(spezi.modules.count, 8) + XCTAssertEqual(spezi.modules.count, 9) return spezi } From 3897e1cc86406b524883c48533add7390c27a72c Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Sun, 23 Jun 2024 22:15:41 +0200 Subject: [PATCH 4/8] Resolve todos --- Tests/SpeziTests/DependenciesTests/DependencyTests.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Tests/SpeziTests/DependenciesTests/DependencyTests.swift b/Tests/SpeziTests/DependenciesTests/DependencyTests.swift index e9ee0bbc..8ac7ec79 100644 --- a/Tests/SpeziTests/DependenciesTests/DependencyTests.swift +++ b/Tests/SpeziTests/DependenciesTests/DependencyTests.swift @@ -304,10 +304,6 @@ final class DependencyTests: XCTestCase { // swiftlint:disable:this type_body_le XCTAssertEqual(optionalModule.nums, [5, 3]) - - // TODO: test more! - // TODO: test viewModifers as well! - wait(for: [deinitExpectation]) } From 78129758ab1115a01a9d9e11400147b0f39166a0 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Wed, 26 Jun 2024 14:29:49 +0200 Subject: [PATCH 5/8] Enable strict concurrecny checking --- Package.swift | 25 ++++++++++---- .../RegisterRemoteNotificationsAction.swift | 2 +- .../UnregisterRemoteNotificationsAction.swift | 1 + .../Observable/EnvironmentAccessible.swift | 4 +-- .../Observable/ModelPropertyWrapper.swift | 4 +-- .../ModelModifierInitialization.swift | 27 +++++++++++++++ .../ModifierPropertyWrapper.swift | 4 +-- .../ViewModifierInitialization.swift | 22 ++++++++++++ .../ViewModifier/ViewModifierProvider.swift | 6 ++-- .../ViewModifier/WrappedViewModifier.swift | 25 ++++++++++++++ .../KnowledgeSources/LaunchOptionsKey.swift | 5 ++- Sources/Spezi/Spezi/ModuleDescription.swift | 28 +++++++++++++++ Sources/Spezi/Spezi/Spezi+Logger.swift | 7 ---- Sources/Spezi/Spezi/Spezi+Preview.swift | 2 +- Sources/Spezi/Spezi/Spezi.swift | 14 ++++---- Sources/Spezi/Spezi/SpeziAppDelegate.swift | 7 ++-- .../SpeziNotificationCenterDelegate.swift | 34 ++++++++++--------- Sources/Spezi/Spezi/View+Spezi.swift | 2 ++ Sources/XCTSpezi/DependencyResolution.swift | 2 ++ .../ViewModifierTests/ViewModifierTests.swift | 6 ++-- .../DependenciesTests/DependencyTests.swift | 7 +++- .../DynamicDependenciesTests.swift | 16 +++++++-- .../SpeziTests/ModuleTests/ModuleTests.swift | 4 +++ .../TestApp/ModelTests/ModuleWithModel.swift | 2 +- .../UITests/UITests.xcodeproj/project.pbxproj | 6 +++- .../xcshareddata/xcschemes/TestApp.xcscheme | 2 +- 26 files changed, 205 insertions(+), 59 deletions(-) create mode 100644 Sources/Spezi/Capabilities/ViewModifier/ModelModifierInitialization.swift create mode 100644 Sources/Spezi/Capabilities/ViewModifier/ViewModifierInitialization.swift create mode 100644 Sources/Spezi/Capabilities/ViewModifier/WrappedViewModifier.swift create mode 100644 Sources/Spezi/Spezi/ModuleDescription.swift diff --git a/Package.swift b/Package.swift index a872007e..c0581511 100644 --- a/Package.swift +++ b/Package.swift @@ -8,6 +8,7 @@ // SPDX-License-Identifier: MIT // +import class Foundation.ProcessInfo import PackageDescription @@ -39,14 +40,20 @@ let package = Package( .product(name: "XCTRuntimeAssertions", package: "XCTRuntimeAssertions"), .product(name: "OrderedCollections", package: "swift-collections") ], - plugins: [.swiftLintPlugin] + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency") + ], + plugins: [] + swiftLintPlugin() ), .target( name: "XCTSpezi", dependencies: [ .target(name: "Spezi") ], - plugins: [.swiftLintPlugin] + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency") + ], + plugins: [] + swiftLintPlugin() ), .testTarget( name: "SpeziTests", @@ -55,13 +62,19 @@ let package = Package( .target(name: "XCTSpezi"), .product(name: "XCTRuntimeAssertions", package: "XCTRuntimeAssertions") ], - plugins: [.swiftLintPlugin] + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency") + ], + plugins: [] + swiftLintPlugin() ) ] ) -extension Target.PluginUsage { - static var swiftLintPlugin: Target.PluginUsage { - .plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLint") +func swiftLintPlugin() -> [Target.PluginUsage] { + // Fully quit Xcode and open again with `open --env SPEZI_DEVELOPMENT_SWIFTLINT /Applications/Xcode.app` + if ProcessInfo.processInfo.environment["SPEZI_DEVELOPMENT_SWIFTLINT"] != nil { + [.plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLint")] + } else { + [] } } diff --git a/Sources/Spezi/Capabilities/Notifications/RegisterRemoteNotificationsAction.swift b/Sources/Spezi/Capabilities/Notifications/RegisterRemoteNotificationsAction.swift index 08264d40..07df38e3 100644 --- a/Sources/Spezi/Capabilities/Notifications/RegisterRemoteNotificationsAction.swift +++ b/Sources/Spezi/Capabilities/Notifications/RegisterRemoteNotificationsAction.swift @@ -10,7 +10,7 @@ import SpeziFoundation import SwiftUI -private class RemoteNotificationContinuation: DefaultProvidingKnowledgeSource { +private final class RemoteNotificationContinuation: DefaultProvidingKnowledgeSource { typealias Anchor = SpeziAnchor static let defaultValue = RemoteNotificationContinuation() diff --git a/Sources/Spezi/Capabilities/Notifications/UnregisterRemoteNotificationsAction.swift b/Sources/Spezi/Capabilities/Notifications/UnregisterRemoteNotificationsAction.swift index 4bb081eb..1d2e1167 100644 --- a/Sources/Spezi/Capabilities/Notifications/UnregisterRemoteNotificationsAction.swift +++ b/Sources/Spezi/Capabilities/Notifications/UnregisterRemoteNotificationsAction.swift @@ -32,6 +32,7 @@ public struct UnregisterRemoteNotificationsAction { /// Unregisters for all remote notifications received through Apple Push Notification service. + @MainActor public func callAsFunction() { #if os(watchOS) let application = _Application.shared() diff --git a/Sources/Spezi/Capabilities/Observable/EnvironmentAccessible.swift b/Sources/Spezi/Capabilities/Observable/EnvironmentAccessible.swift index 16e1eaa1..19422a5d 100644 --- a/Sources/Spezi/Capabilities/Observable/EnvironmentAccessible.swift +++ b/Sources/Spezi/Capabilities/Observable/EnvironmentAccessible.swift @@ -32,7 +32,7 @@ public protocol EnvironmentAccessible: AnyObject, Observable {} extension EnvironmentAccessible { - var viewModifier: any ViewModifier { - ModelModifier(model: self) + var viewModifierInitialization: any ViewModifierInitialization { + ModelModifierInitialization(model: self) } } diff --git a/Sources/Spezi/Capabilities/Observable/ModelPropertyWrapper.swift b/Sources/Spezi/Capabilities/Observable/ModelPropertyWrapper.swift index 255b91c1..8171c09f 100644 --- a/Sources/Spezi/Capabilities/Observable/ModelPropertyWrapper.swift +++ b/Sources/Spezi/Capabilities/Observable/ModelPropertyWrapper.swift @@ -97,7 +97,7 @@ extension Module { extension _ModelPropertyWrapper: ViewModifierProvider { - var viewModifier: (any ViewModifier)? { + var viewModifierInitialization: (any ViewModifierInitialization)? { collected = true guard let storedValue else { @@ -105,7 +105,7 @@ extension _ModelPropertyWrapper: ViewModifierProvider { return nil } - return ModelModifier(model: storedValue) + return ModelModifierInitialization(model: storedValue) } var placement: ModifierPlacement { diff --git a/Sources/Spezi/Capabilities/ViewModifier/ModelModifierInitialization.swift b/Sources/Spezi/Capabilities/ViewModifier/ModelModifierInitialization.swift new file mode 100644 index 00000000..a0f28bbe --- /dev/null +++ b/Sources/Spezi/Capabilities/ViewModifier/ModelModifierInitialization.swift @@ -0,0 +1,27 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + + +import SwiftUI + + +struct ModelModifierInitialization: ViewModifierInitialization, @unchecked Sendable { + // @uncheked Sendable is fine as we are never allowing to mutate the non-Sendable `Model`. + // The `Model` will be passed to the MainActor (and be accessible from the SwiftUI environment). Those interactions + // are out of scope and expected to be handled by the Application developer (typically Model will be fully MainActor isolated anyways). + // We just make sure with this wrapper that no interaction can happen till the Model arrives on the MainActor. + private let model: Model + + init(model: Model) { + self.model = model + } + + func initializeModifier() -> some ViewModifier { + ModelModifier(model: model) + } +} diff --git a/Sources/Spezi/Capabilities/ViewModifier/ModifierPropertyWrapper.swift b/Sources/Spezi/Capabilities/ViewModifier/ModifierPropertyWrapper.swift index c67d5e90..c9b9828a 100644 --- a/Sources/Spezi/Capabilities/ViewModifier/ModifierPropertyWrapper.swift +++ b/Sources/Spezi/Capabilities/ViewModifier/ModifierPropertyWrapper.swift @@ -97,7 +97,7 @@ extension Module { extension _ModifierPropertyWrapper: ViewModifierProvider { - var viewModifier: (any ViewModifier)? { + var viewModifierInitialization: (any ViewModifierInitialization)? { collected = true guard let storedValue else { @@ -105,6 +105,6 @@ extension _ModifierPropertyWrapper: ViewModifierProvider { return nil } - return storedValue + return WrappedViewModifier(modifier: storedValue) } } diff --git a/Sources/Spezi/Capabilities/ViewModifier/ViewModifierInitialization.swift b/Sources/Spezi/Capabilities/ViewModifier/ViewModifierInitialization.swift new file mode 100644 index 00000000..9176656d --- /dev/null +++ b/Sources/Spezi/Capabilities/ViewModifier/ViewModifierInitialization.swift @@ -0,0 +1,22 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + + +import SwiftUI + + +/// Capture the possibility to initialize a `ViewModifier`. +/// +/// With Swift 6, even the ViewModifier initializers are isolated to MainActor. Therefore, we need to delay initialization +/// of ViewModifiers to the point where we are on the MainActor. +protocol ViewModifierInitialization: Sendable { + associatedtype Modifier: ViewModifier + + @MainActor + func initializeModifier() -> Modifier +} diff --git a/Sources/Spezi/Capabilities/ViewModifier/ViewModifierProvider.swift b/Sources/Spezi/Capabilities/ViewModifier/ViewModifierProvider.swift index d3de6208..60ff4099 100644 --- a/Sources/Spezi/Capabilities/ViewModifier/ViewModifierProvider.swift +++ b/Sources/Spezi/Capabilities/ViewModifier/ViewModifierProvider.swift @@ -28,7 +28,7 @@ protocol ViewModifierProvider { /// The view modifier instance that should be injected into the SwiftUI view hierarchy. /// /// Does nothing if `nil` is provided. - var viewModifier: (any ViewModifier)? { get } + var viewModifierInitialization: (any ViewModifierInitialization)? { get } /// Defines the placement order of this view modifier. /// @@ -48,13 +48,13 @@ extension ViewModifierProvider { extension Module { /// All SwiftUI `ViewModifier` the module wants to modify the global view hierarchy with. - var viewModifierEntires: [(UUID, any SwiftUI.ViewModifier)] { + var viewModifierEntires: [(UUID, any ViewModifierInitialization)] { retrieveProperties(ofType: ViewModifierProvider.self) .sorted { lhs, rhs in lhs.placement < rhs.placement } .compactMap { provider in - guard let modifier = provider.viewModifier else { + guard let modifier = provider.viewModifierInitialization else { return nil } return (provider.id, modifier) diff --git a/Sources/Spezi/Capabilities/ViewModifier/WrappedViewModifier.swift b/Sources/Spezi/Capabilities/ViewModifier/WrappedViewModifier.swift new file mode 100644 index 00000000..87fdf484 --- /dev/null +++ b/Sources/Spezi/Capabilities/ViewModifier/WrappedViewModifier.swift @@ -0,0 +1,25 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +struct WrappedViewModifier: ViewModifierInitialization, @unchecked Sendable { + // @uncheked Sendable is fine as we are never allowing to mutate the non-Sendable `Model` till it arrives on the MainActor. + // ViewModifiers must be instantiated on the MainActor and `initializedModifier()` will only release the Modifier once it arrives on the MainActor. + // So this is essentially just a storage to pass around the Modifier between different actors but guarantees that it never leaves the MainActor. + private let modifier: Modifier + + init(modifier: Modifier) { + self.modifier = modifier + } + + func initializeModifier() -> Modifier { + modifier + } +} diff --git a/Sources/Spezi/Spezi/KnowledgeSources/LaunchOptionsKey.swift b/Sources/Spezi/Spezi/KnowledgeSources/LaunchOptionsKey.swift index 59f8b460..bf49decb 100644 --- a/Sources/Spezi/Spezi/KnowledgeSources/LaunchOptionsKey.swift +++ b/Sources/Spezi/Spezi/KnowledgeSources/LaunchOptionsKey.swift @@ -23,7 +23,10 @@ public struct LaunchOptionsKey: DefaultProvidingKnowledgeSource { public typealias Value = [Never: Any] #endif - public static let defaultValue: Value = [:] + // We inherit the type from UIKit, Any is inherently unsafe and also contains objects which might not conform to sendable. + // The dictionary access itself is not unsafe and our empty default value isn't as well. + // So annotating it as non-isolated is fine and passing LaunchOptions Values around actor boundaries is specific to the application. + public static nonisolated(unsafe) let defaultValue: Value = [:] } diff --git a/Sources/Spezi/Spezi/ModuleDescription.swift b/Sources/Spezi/Spezi/ModuleDescription.swift new file mode 100644 index 00000000..2655603b --- /dev/null +++ b/Sources/Spezi/Spezi/ModuleDescription.swift @@ -0,0 +1,28 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + + +/// A description of a `Module`. +struct ModuleDescription { + let name: String + + var loggerCategory: String { + name + } + + init(from module: M) { + self.name = "\(M.self)" + } +} + + +extension Module { + var moduleDescription: ModuleDescription { + ModuleDescription(from: self) + } +} diff --git a/Sources/Spezi/Spezi/Spezi+Logger.swift b/Sources/Spezi/Spezi/Spezi+Logger.swift index 3c6f04a4..2a373fad 100644 --- a/Sources/Spezi/Spezi/Spezi+Logger.swift +++ b/Sources/Spezi/Spezi/Spezi+Logger.swift @@ -10,13 +10,6 @@ import os import SpeziFoundation -extension Module { - fileprivate var loggerCategory: String { - "\(Self.self)" - } -} - - extension Spezi { /// Access the application logger. /// diff --git a/Sources/Spezi/Spezi/Spezi+Preview.swift b/Sources/Spezi/Spezi/Spezi+Preview.swift index 84298c17..c0f563e8 100644 --- a/Sources/Spezi/Spezi/Spezi+Preview.swift +++ b/Sources/Spezi/Spezi/Spezi+Preview.swift @@ -12,7 +12,7 @@ import XCTRuntimeAssertions /// Options to simulate behavior for a ``LifecycleHandler`` in cases where there is no app delegate like in Preview setups. -public enum LifecycleSimulationOptions { +public enum LifecycleSimulationOptions: @unchecked Sendable { // see discussion in `LaunchOptionsKey` /// Simulation is disabled. case disabled #if os(iOS) || os(visionOS) || os(tvOS) diff --git a/Sources/Spezi/Spezi/Spezi.swift b/Sources/Spezi/Spezi/Spezi.swift index 32838ac9..230564e7 100644 --- a/Sources/Spezi/Spezi/Spezi.swift +++ b/Sources/Spezi/Spezi/Spezi.swift @@ -122,8 +122,8 @@ struct WeaklyStoredModule: KnowledgeSource { public class Spezi { static let logger = Logger(subsystem: "edu.stanford.spezi", category: "Spezi") - @TaskLocal static var moduleInitContext: (any Module)? - + @TaskLocal static var moduleInitContext: ModuleDescription? + let standard: any Standard /// Recursive lock for module loading. @@ -135,12 +135,12 @@ public class Spezi { fileprivate(set) var storage: SpeziStorage /// Key is either a UUID for `@Modifier` or `@Model` property wrappers, or a `ModuleReference` for `EnvironmentAccessible` modifiers. - private var _viewModifiers: OrderedDictionary = [:] + private var _viewModifiers: OrderedDictionary = [:] /// Array of all SwiftUI `ViewModifiers` collected using `_ModifierPropertyWrapper` from the configured ``Module``s. /// /// Any changes to this property will cause a complete re-render of the SwiftUI view hierarchy. See `SpeziViewModifier`. - var viewModifiers: [any ViewModifier] { + var viewModifiers: [any ViewModifierInitialization] { _viewModifiers.reduce(into: []) { partialResult, entry in partialResult.append(entry.value) } @@ -253,7 +253,7 @@ public class Spezi { // we pass through the whole list of modules once to collect all @Provide values for module in dependencyManager.initializedModules { - Self.$moduleInitContext.withValue(module) { + Self.$moduleInitContext.withValue(module.moduleDescription) { module.collectModuleValues(into: &storage) } } @@ -335,7 +335,7 @@ public class Spezi { private func initModule(_ module: any Module, ownership: ModuleOwnership) { precondition(!module.isLoaded(in: self), "Tried to initialize Module \(type(of: module)) that was already loaded!") - Self.$moduleInitContext.withValue(module) { + Self.$moduleInitContext.withValue(module.moduleDescription) { module.inject(spezi: self) // supply modules values to all @Collect @@ -362,7 +362,7 @@ public class Spezi { if let observable = module as? EnvironmentAccessible { // we can't guarantee weak references for EnvironmentAccessible modules precondition(ownership != .external, "Modules loaded with self-managed policy cannot conform to `EnvironmentAccessible`.") - _viewModifiers[ModuleReference(module)] = observable.viewModifier + _viewModifiers[ModuleReference(module)] = observable.viewModifierInitialization } } } diff --git a/Sources/Spezi/Spezi/SpeziAppDelegate.swift b/Sources/Spezi/Spezi/SpeziAppDelegate.swift index 511ad798..6f46a03b 100644 --- a/Sources/Spezi/Spezi/SpeziAppDelegate.swift +++ b/Sources/Spezi/Spezi/SpeziAppDelegate.swift @@ -165,14 +165,15 @@ open class SpeziAppDelegate: NSObject, ApplicationDelegate { let result: Set = await withTaskGroup(of: BackgroundFetchResult.self) { group in for handler in handlers { - group.addTask { + group.addTask { @MainActor in await handler.receiveRemoteNotification(userInfo) } } var result: Set = [] - for await backgroundFetchResult in group { - result.insert(backgroundFetchResult) + while let next = await group.next() { + // don't ask why, but the for in or .reduce versions trigger Swift 6 concurrency warnings, this one doesn't + result.insert(next) } return result } diff --git a/Sources/Spezi/Spezi/SpeziNotificationCenterDelegate.swift b/Sources/Spezi/Spezi/SpeziNotificationCenterDelegate.swift index 80eebd42..174809df 100644 --- a/Sources/Spezi/Spezi/SpeziNotificationCenterDelegate.swift +++ b/Sources/Spezi/Spezi/SpeziNotificationCenterDelegate.swift @@ -6,42 +6,44 @@ // SPDX-License-Identifier: MIT // -import UserNotifications +@preconcurrency import UserNotifications class SpeziNotificationCenterDelegate: NSObject, UNUserNotificationCenterDelegate { #if !os(tvOS) - @MainActor + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { - guard let delegate = SpeziAppDelegate.appDelegate else { - return - } + await withTaskGroup(of: Void.self) { @MainActor group in + // Moving this inside here (@MainActor isolated task group body) helps us avoid making the whole delegate method @MainActor. + // Apparently having the non-Sendable `UNNotificationResponse` as a parameter to a @MainActor annotated method doesn't suppress + // the warning with @preconcurrency, but capturing `response` in a @MainActor isolated closure does. + guard let delegate = SpeziAppDelegate.appDelegate else { + return + } - await withTaskGroup(of: Void.self) { group in for handler in delegate.spezi.notificationHandler { - group.addTask { + group.addTask { @MainActor in await handler.handleNotificationAction(response) } } - for await _ in group {} + await group.waitForAll() } } #endif - @MainActor func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification ) async -> UNNotificationPresentationOptions { - guard let delegate = SpeziAppDelegate.appDelegate else { - return [] - } - + await withTaskGroup(of: UNNotificationPresentationOptions?.self) { @MainActor group in + // See comment in method above. + guard let delegate = SpeziAppDelegate.appDelegate else { + return [] + } - return await withTaskGroup(of: UNNotificationPresentationOptions?.self) { group in for handler in delegate.spezi.notificationHandler { - group.addTask { + group.addTask { @MainActor in await handler.receiveIncomingNotification(notification) } } @@ -49,7 +51,7 @@ class SpeziNotificationCenterDelegate: NSObject, UNUserNotificationCenterDelegat var hasSpecified = false var unionOptions: UNNotificationPresentationOptions = [] - for await options in group { + while let options = await group.next() { guard let options else { continue } diff --git a/Sources/Spezi/Spezi/View+Spezi.swift b/Sources/Spezi/Spezi/View+Spezi.swift index e0972ae4..5801a446 100644 --- a/Sources/Spezi/Spezi/View+Spezi.swift +++ b/Sources/Spezi/Spezi/View+Spezi.swift @@ -21,6 +21,7 @@ struct SpeziViewModifier: ViewModifier { func body(content: Content) -> some View { spezi.viewModifiers + .map { $0.initializeModifier() } .modify(content) } } @@ -37,6 +38,7 @@ extension View { extension Array where Element == any ViewModifier { + @MainActor fileprivate func modify(_ view: V) -> AnyView { var view = AnyView(view) for modifier in self { diff --git a/Sources/XCTSpezi/DependencyResolution.swift b/Sources/XCTSpezi/DependencyResolution.swift index 8cda4a18..5e45f094 100644 --- a/Sources/XCTSpezi/DependencyResolution.swift +++ b/Sources/XCTSpezi/DependencyResolution.swift @@ -18,6 +18,7 @@ import SwiftUI /// - standard: The Spezi [`Standard`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/standard) to initialize. /// - simulateLifecycle: Options to simulate behavior for [`LifecycleHandler`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/lifecyclehandler)s. /// - modules: The collection of Modules that are configured. +@MainActor public func withDependencyResolution( standard: S, simulateLifecycle: LifecycleSimulationOptions = .disabled, @@ -45,6 +46,7 @@ public func withDependencyResolution( /// - Parameters: /// - simulateLifecycle: Options to simulate behavior for [`LifecycleHandler`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/lifecyclehandler)s. /// - modules: The collection of Modules that are configured. +@MainActor public func withDependencyResolution( simulateLifecycle: LifecycleSimulationOptions = .disabled, @ModuleBuilder _ modules: () -> ModuleCollection diff --git a/Tests/SpeziTests/CapabilityTests/ViewModifierTests/ViewModifierTests.swift b/Tests/SpeziTests/CapabilityTests/ViewModifierTests/ViewModifierTests.swift index f69641c6..063e5c1b 100644 --- a/Tests/SpeziTests/CapabilityTests/ViewModifierTests/ViewModifierTests.swift +++ b/Tests/SpeziTests/CapabilityTests/ViewModifierTests/ViewModifierTests.swift @@ -13,6 +13,7 @@ import XCTRuntimeAssertions final class ViewModifierTests: XCTestCase { + @MainActor func testViewModifierRetrieval() throws { let expectation = XCTestExpectation(description: "Module") expectation.assertForOverFulfill = true @@ -22,9 +23,10 @@ final class ViewModifierTests: XCTestCase { let modifiers = testApplicationDelegate.spezi.viewModifiers XCTAssertEqual(modifiers.count, 2) + print(modifiers) let message = modifiers - .compactMap { $0 as? TestViewModifier } - .map { $0.message } + .compactMap { $0 as? WrappedViewModifier } + .map { $0.initializeModifier().message } .joined(separator: " ") XCTAssertEqual(message, "Hello World") } diff --git a/Tests/SpeziTests/DependenciesTests/DependencyTests.swift b/Tests/SpeziTests/DependenciesTests/DependencyTests.swift index 8ac7ec79..6ff1cae8 100644 --- a/Tests/SpeziTests/DependenciesTests/DependencyTests.swift +++ b/Tests/SpeziTests/DependenciesTests/DependencyTests.swift @@ -84,10 +84,12 @@ private final class TestModule7: Module { @Dependency var testModule1 = TestModule1() } +// Swift 6 compiler doesn't allow circular references (even when there is a property wrapper in between) +// Review this in future versions. +#if compiler(<6) private final class TestModuleCircle1: Module { @Dependency var testModuleCircle2 = TestModuleCircle2() } - private final class TestModuleCircle2: Module { @Dependency var testModuleCircle1 = TestModuleCircle1() } @@ -95,6 +97,7 @@ private final class TestModuleCircle2: Module { private final class TestModuleItself: Module { @Dependency var testModuleItself = TestModuleItself() } +#endif private final class OptionalModuleDependency: Module { @@ -430,6 +433,7 @@ final class DependencyTests: XCTestCase { // swiftlint:disable:this type_body_le _ = try XCTUnwrap(initializedModules[2] as? TestModule5) } +#if compiler(<6) func testModuleCycle() throws { let modules: [any Module] = [ TestModuleCircle1() @@ -439,6 +443,7 @@ final class DependencyTests: XCTestCase { // swiftlint:disable:this type_body_le _ = DependencyManager.resolve(modules) } } +#endif func testOptionalDependenceNonPresent() throws { let nonPresent: [any Module] = [ diff --git a/Tests/SpeziTests/DependenciesTests/DynamicDependenciesTests.swift b/Tests/SpeziTests/DependenciesTests/DynamicDependenciesTests.swift index af28613a..d0ddd909 100644 --- a/Tests/SpeziTests/DependenciesTests/DynamicDependenciesTests.swift +++ b/Tests/SpeziTests/DependenciesTests/DynamicDependenciesTests.swift @@ -17,8 +17,10 @@ private enum DynamicDependenciesTestCase: CaseIterable { case twoDependencies case duplicatedDependencies case noDependencies +#if compiler(<6) case dependencyCircle - +#endif + var dynamicDependencies: _DependencyPropertyWrapper<[any Module]> { switch self { @@ -35,11 +37,13 @@ private enum DynamicDependenciesTestCase: CaseIterable { } case .noDependencies: return .init() +#if compiler(<6) case .dependencyCircle: return .init { TestModuleCircle1() TestModuleCircle2() } +#endif } } @@ -49,9 +53,11 @@ private enum DynamicDependenciesTestCase: CaseIterable { return 3 case .noDependencies: return 1 +#if compiler(<6) case .dependencyCircle: XCTFail("Should never be called!") return -1 +#endif } } @@ -70,8 +76,10 @@ private enum DynamicDependenciesTestCase: CaseIterable { XCTAssert(testModule2 !== testModule3) case .noDependencies: XCTAssertEqual(modules.count, 0) +#if compiler(<6) case .dependencyCircle: XCTFail("Should never be called!") +#endif } } } @@ -96,6 +104,7 @@ private final class TestModule2: Module {} private final class TestModule3: Module {} +#if compiler(<6) private final class TestModuleCircle1: Module { @Dependency var testModuleCircle2 = TestModuleCircle2() } @@ -103,6 +112,7 @@ private final class TestModuleCircle1: Module { private final class TestModuleCircle2: Module { @Dependency var testModuleCircle1 = TestModuleCircle1() } +#endif final class DynamicDependenciesTests: XCTestCase { @@ -112,13 +122,15 @@ final class DynamicDependenciesTests: XCTestCase { TestModule1(dynamicDependenciesTestCase) ] +#if compiler(<6) guard dynamicDependenciesTestCase != .dependencyCircle else { try XCTRuntimePrecondition { _ = DependencyManager.resolve(modules) } return } - +#endif + let initializedModules = DependencyManager.resolve(modules) XCTAssertEqual(initializedModules.count, dynamicDependenciesTestCase.expectedNumberOfModules) diff --git a/Tests/SpeziTests/ModuleTests/ModuleTests.swift b/Tests/SpeziTests/ModuleTests/ModuleTests.swift index 78301bed..15979b1c 100644 --- a/Tests/SpeziTests/ModuleTests/ModuleTests.swift +++ b/Tests/SpeziTests/ModuleTests/ModuleTests.swift @@ -31,6 +31,7 @@ private final class DependingTestModule: Module { final class ModuleTests: XCTestCase { + @MainActor func testModuleFlow() throws { let expectation = XCTestExpectation(description: "Module") expectation.assertForOverFulfill = true @@ -52,6 +53,7 @@ final class ModuleTests: XCTestCase { XCTAssert(modules.contains(where: { $0 is TestModule })) } + @MainActor func testPreviewModifier() throws { let expectation = XCTestExpectation(description: "Preview Module") expectation.assertForOverFulfill = true @@ -70,6 +72,7 @@ final class ModuleTests: XCTestCase { unsetenv(ProcessInfo.xcodeRunningForPreviewKey) } + @MainActor func testPreviewModifierOnlyWithinPreview() throws { try XCTRuntimePrecondition { _ = Text("Spezi") @@ -79,6 +82,7 @@ final class ModuleTests: XCTestCase { } } + @MainActor func testModuleCreation() { let expectation = XCTestExpectation(description: "DependingTestModule") expectation.assertForOverFulfill = true diff --git a/Tests/UITests/TestApp/ModelTests/ModuleWithModel.swift b/Tests/UITests/TestApp/ModelTests/ModuleWithModel.swift index 598bcda5..766a0a11 100644 --- a/Tests/UITests/TestApp/ModelTests/ModuleWithModel.swift +++ b/Tests/UITests/TestApp/ModelTests/ModuleWithModel.swift @@ -11,7 +11,7 @@ import SwiftUI struct CustomKey: EnvironmentKey { - static var defaultValue = false + static let defaultValue = false } @Observable diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index ea344483..a2216734 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -227,7 +227,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1410; - LastUpgradeCheck = 1520; + LastUpgradeCheck = 1540; TargetAttributes = { 2F6D139128F5F384007C25D6 = { CreatedOnToolsVersion = 14.1; @@ -376,6 +376,7 @@ ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_TESTING_SEARCH_PATHS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -398,6 +399,7 @@ SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; TVOS_DEPLOYMENT_TARGET = 17.0; XROS_DEPLOYMENT_TARGET = 1.0; }; @@ -441,6 +443,7 @@ ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTING_SEARCH_PATHS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -456,6 +459,7 @@ SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_STRICT_CONCURRENCY = complete; TVOS_DEPLOYMENT_TARGET = 17.0; VALIDATE_PRODUCT = YES; XROS_DEPLOYMENT_TARGET = 1.0; diff --git a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme index b0fde679..eb317413 100644 --- a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme +++ b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme @@ -1,6 +1,6 @@ Date: Wed, 26 Jun 2024 14:34:41 +0200 Subject: [PATCH 6/8] Don't pull in the package by default --- Package.swift | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/Package.swift b/Package.swift index c0581511..6ccca6aa 100644 --- a/Package.swift +++ b/Package.swift @@ -29,9 +29,8 @@ let package = Package( dependencies: [ .package(url: "https://github.com/StanfordSpezi/SpeziFoundation", from: "1.0.2"), .package(url: "https://github.com/StanfordBDHG/XCTRuntimeAssertions", from: "1.0.1"), - .package(url: "https://github.com/apple/swift-collections.git", from: "1.1.1"), - .package(url: "https://github.com/realm/SwiftLint.git", .upToNextMinor(from: "0.55.1")) - ], + .package(url: "https://github.com/apple/swift-collections.git", from: "1.1.1") + ] + swiftLintPackage(), targets: [ .target( name: "Spezi", @@ -78,3 +77,11 @@ func swiftLintPlugin() -> [Target.PluginUsage] { [] } } + +func swiftLintPackage() -> [PackageDescription.Package.Dependency] { + if ProcessInfo.processInfo.environment["SPEZI_DEVELOPMENT_SWIFTLINT"] != nil { + [.package(url: "https://github.com/realm/SwiftLint.git", .upToNextMinor(from: "0.55.1"))] + } else { + [] + } +} From cc714842e2f725ef0aa1b3c0835ffacbe84a9ca5 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Wed, 26 Jun 2024 14:48:30 +0200 Subject: [PATCH 7/8] Make sure UITests have strict concurrency --- Tests/UITests/TestAppUITests/LifecycleHandlerTests.swift | 1 + Tests/UITests/TestAppUITests/ModelTests.swift | 1 + Tests/UITests/TestAppUITests/ViewModifierTests.swift | 1 + 3 files changed, 3 insertions(+) diff --git a/Tests/UITests/TestAppUITests/LifecycleHandlerTests.swift b/Tests/UITests/TestAppUITests/LifecycleHandlerTests.swift index 509935b8..06b8e8e8 100644 --- a/Tests/UITests/TestAppUITests/LifecycleHandlerTests.swift +++ b/Tests/UITests/TestAppUITests/LifecycleHandlerTests.swift @@ -11,6 +11,7 @@ import XCTestExtensions final class LifecycleHandlerTests: XCTestCase { + @MainActor func testLifecycleHandler() throws { #if os(macOS) || os(watchOS) throw XCTSkip("LifecycleHandler is not supported on macOS or watchOS.") diff --git a/Tests/UITests/TestAppUITests/ModelTests.swift b/Tests/UITests/TestAppUITests/ModelTests.swift index 105729ff..8cf5f543 100644 --- a/Tests/UITests/TestAppUITests/ModelTests.swift +++ b/Tests/UITests/TestAppUITests/ModelTests.swift @@ -10,6 +10,7 @@ import XCTest final class ModelTests: XCTestCase { + @MainActor func testModelPropertyWrapper() throws { let app = XCUIApplication() app.launch() diff --git a/Tests/UITests/TestAppUITests/ViewModifierTests.swift b/Tests/UITests/TestAppUITests/ViewModifierTests.swift index 5ea663a2..73c53a5d 100644 --- a/Tests/UITests/TestAppUITests/ViewModifierTests.swift +++ b/Tests/UITests/TestAppUITests/ViewModifierTests.swift @@ -10,6 +10,7 @@ import XCTest final class ViewModifierTests: XCTestCase { + @MainActor func testViewModifierPropertyWrapper() throws { let app = XCUIApplication() app.launch() From 18550254e32642cb960febcab33bf384291ab857 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Wed, 26 Jun 2024 15:15:33 +0200 Subject: [PATCH 8/8] Make sure it works for both compiler versions --- Package.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Package.swift b/Package.swift index 6ccca6aa..2af3e524 100644 --- a/Package.swift +++ b/Package.swift @@ -11,6 +11,12 @@ import class Foundation.ProcessInfo import PackageDescription +#if swift(<6) +let swiftConcurrency: SwiftSetting = .enableExperimentalFeature("SwiftConcurrency") +#else +let swiftConcurrency: SwiftSetting = .enableUpcomingFeature("SwiftConcurrency") +#endif + let package = Package( name: "Spezi", @@ -40,7 +46,7 @@ let package = Package( .product(name: "OrderedCollections", package: "swift-collections") ], swiftSettings: [ - .enableUpcomingFeature("StrictConcurrency") + swiftConcurrency ], plugins: [] + swiftLintPlugin() ), @@ -50,7 +56,7 @@ let package = Package( .target(name: "Spezi") ], swiftSettings: [ - .enableUpcomingFeature("StrictConcurrency") + swiftConcurrency ], plugins: [] + swiftLintPlugin() ), @@ -62,7 +68,7 @@ let package = Package( .product(name: "XCTRuntimeAssertions", package: "XCTRuntimeAssertions") ], swiftSettings: [ - .enableUpcomingFeature("StrictConcurrency") + swiftConcurrency ], plugins: [] + swiftLintPlugin() )