From 4513a697572e8e1faea1e0ee52e6fad4b8d3dd8d Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Mon, 28 Oct 2024 20:57:41 +0100 Subject: [PATCH] Provide integration points for SpeziNotifications, Swift 6 and silence some warnings (#117) # Provide integration points for SpeziNotifications, Swift 6 and silence some warnings ## :recycle: Current situation & Problem SpeziNotifications (https://github.com/StanfordSpezi/SpeziNotifications/pull/1) is a new framework in the Spezi ecosystem that helps dealing with UserNotifications in your application. Some elements that are currently defined in Spezi are going to move to SpeziNotifications. We will fully move all of this infrastructure in a future breaking release of Spezi. For now, we make sure to have some of the required infrastructure accessible by SpeziNetworking. The `SpeziNotificationCenterDelegate` and associated protocols will stay for now but can eventually be fully moved to SpeziNotifications. For now, SpeziNotifications will re-export all relevant types so it is easier for users to be prepared for the change. ## :gear: Release Notes * Deprecated the remote notifications actions declared on Spezi. SpeziNotifications declares the exact same actions. * Allow SpeziNotifications to receive the delegate calls that are made after calling `registerForRemoteNotifications`. * Move the Package to the Swift 6 toolchain. * Silence some deprecation warnings through some visibility tricks. ## :books: Documentation -- ## :white_check_mark: Testing -- ## :pencil: Code of Conduct & Contributing Guidelines By submitting creating this pull request, you agree to follow our [Code of Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md) and [Contributing Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md): - [x] I agree to follow the [Code of Conduct](https://github.com/StanfordSpezi/.github/blob/main/CODE_OF_CONDUCT.md) and [Contributing Guidelines](https://github.com/StanfordSpezi/.github/blob/main/CONTRIBUTING.md). --- .github/workflows/build-and-test.yml | 21 -- .spi.yml | 1 + Package.swift | 21 +- .../ApplicationPropertyWrapper.swift | 29 ++- .../RegisterRemoteNotificationsAction.swift | 242 ------------------ .../Dependencies/DependencyManager.swift | 49 ++-- .../Dependencies/DependencyManagerError.swift | 35 +++ .../Property/DependencyCollection.swift | 8 +- .../Property/DependencyContext.swift | 8 +- .../Property/DependencyDeclaration.swift | 4 +- .../Property/DependencyPropertyWrapper.swift | 8 +- .../BackgroundFetchResult.swift | 0 .../Notifications/NotificationHandler.swift | 0 .../NotificationTokenHandler.swift | 0 ...emoteNotificationRegistrationSupport.swift | 90 +++++++ ...zi+RegisterRemoteNotificationsAction.swift | 106 ++++++++ ...Spezi+UnregisterRemoteNotifications.swift} | 49 ++-- .../SpeziNotificationCenterDelegate.swift | 8 +- .../Interactions with Application.md | 42 +++ .../Module/Interactions with Application.md | 2 +- .../Spezi/Spezi.docc/Module/Notifications.md | 5 +- Sources/Spezi/Spezi.docc/Spezi.md | 6 +- Sources/Spezi/Spezi/Spezi+Preview.swift | 26 +- Sources/Spezi/Spezi/Spezi.swift | 132 +++++++--- Sources/Spezi/Spezi/SpeziAppDelegate.swift | 20 +- Sources/Spezi/Spezi/SpeziModuleError.swift | 26 ++ .../Spezi/Spezi/SpeziPropertyWrapper.swift | 40 ++- .../Standard/StandardPropertyWrapper.swift | 26 +- ...tion.swift => Application+TypeAlias.swift} | 8 + Sources/XCTSpezi/DependencyResolution.swift | 5 +- .../ModuleCommunicationTests.swift | 17 +- .../CapabilityTests/NotificationsTests.swift | 3 + .../DependencyBuilderTests.swift | 2 +- .../DependencyManager+OneShot.swift | 11 +- .../DependenciesTests/DependencyTests.swift | 61 +++-- .../DynamicDependenciesTests.swift | 2 +- .../ModuleTests/ModuleBuilderTests.swift | 10 +- .../SpeziTests/ModuleTests/ModuleTests.swift | 10 - .../StandardUnfulfilledConstraintTests.swift | 23 +- .../LifecycleHandlerTestModule.swift | 6 +- .../UITests/UITests.xcodeproj/project.pbxproj | 10 +- .../xcshareddata/xcschemes/TestApp.xcscheme | 2 +- 42 files changed, 652 insertions(+), 522 deletions(-) delete mode 100644 Sources/Spezi/Capabilities/Notifications/RegisterRemoteNotificationsAction.swift create mode 100644 Sources/Spezi/Dependencies/DependencyManagerError.swift rename Sources/Spezi/{Utilities => Notifications}/BackgroundFetchResult.swift (100%) rename Sources/Spezi/{Capabilities => }/Notifications/NotificationHandler.swift (100%) rename Sources/Spezi/{Capabilities => }/Notifications/NotificationTokenHandler.swift (100%) create mode 100644 Sources/Spezi/Notifications/RemoteNotificationRegistrationSupport.swift create mode 100644 Sources/Spezi/Notifications/Spezi+RegisterRemoteNotificationsAction.swift rename Sources/Spezi/{Capabilities/Notifications/UnregisterRemoteNotificationsAction.swift => Notifications/Spezi+UnregisterRemoteNotifications.swift} (58%) rename Sources/Spezi/{Spezi => Notifications}/SpeziNotificationCenterDelegate.swift (85%) create mode 100644 Sources/Spezi/Spezi.docc/Interactions with Application.md create mode 100644 Sources/Spezi/Spezi/SpeziModuleError.swift rename Sources/Spezi/Utilities/{Application.swift => Application+TypeAlias.swift} (77%) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 457d3887..48499587 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -24,16 +24,6 @@ jobs: scheme: Spezi-Package resultBundle: Spezi-Package-iOS.xcresult artifactname: Spezi-Package-iOS.xcresult - buildandtest_ios_latest: - name: Build and Test Swift Package iOS Latest - uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 - with: - runsonlabels: '["macOS", "self-hosted"]' - scheme: Spezi-Package - xcodeversion: latest - swiftVersion: 6 - resultBundle: Spezi-Package-iOS-Latest.xcresult - artifactname: Spezi-Package-iOS-Latest.xcresult buildandtest_watchos: name: Build and Test Swift Package watchOS uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 @@ -79,17 +69,6 @@ jobs: scheme: TestApp resultBundle: TestApp-iOS.xcresult artifactname: TestApp-iOS.xcresult - buildandtestuitests_ios_latest: - name: Build and Test UI Tests iOS Latest - uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 - with: - runsonlabels: '["macOS", "self-hosted"]' - path: Tests/UITests - scheme: TestApp - xcodeversion: latest - swiftVersion: 6 - resultBundle: TestApp-iOS-Latest.xcresult - artifactname: TestApp-iOS-Latest.xcresult buildandtestuitests_visionos: name: Build and Test UI Tests visionOS uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 diff --git a/.spi.yml b/.spi.yml index 27b5b560..5c74bcc4 100644 --- a/.spi.yml +++ b/.spi.yml @@ -11,6 +11,7 @@ builder: configs: - platform: ios scheme: Spezi + swift_version: 6 documentation_targets: - Spezi - XCTSpezi diff --git a/Package.swift b/Package.swift index 98c5072c..81a2d326 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.9 +// swift-tools-version:6.0 // // This source file is part of the Stanford Spezi open-source project @@ -11,12 +11,6 @@ import class Foundation.ProcessInfo import PackageDescription -#if swift(<6) -let swiftConcurrency: SwiftSetting = .enableExperimentalFeature("StrictConcurrency") -#else -let swiftConcurrency: SwiftSetting = .enableUpcomingFeature("StrictConcurrency") -#endif - let package = Package( name: "Spezi", @@ -33,8 +27,8 @@ let package = Package( .library(name: "XCTSpezi", targets: ["XCTSpezi"]) ], dependencies: [ - .package(url: "https://github.com/StanfordSpezi/SpeziFoundation", from: "2.0.0-beta.1"), - .package(url: "https://github.com/StanfordBDHG/XCTRuntimeAssertions", from: "1.1.1"), + .package(url: "https://github.com/StanfordSpezi/SpeziFoundation.git", from: "2.0.0"), + .package(url: "https://github.com/StanfordBDHG/XCTRuntimeAssertions.git", from: "1.1.1"), .package(url: "https://github.com/apple/swift-collections.git", from: "1.1.1") ] + swiftLintPackage(), targets: [ @@ -45,9 +39,6 @@ let package = Package( .product(name: "XCTRuntimeAssertions", package: "XCTRuntimeAssertions"), .product(name: "OrderedCollections", package: "swift-collections") ], - swiftSettings: [ - swiftConcurrency - ], plugins: [] + swiftLintPlugin() ), .target( @@ -55,9 +46,6 @@ let package = Package( dependencies: [ .target(name: "Spezi") ], - swiftSettings: [ - swiftConcurrency - ], plugins: [] + swiftLintPlugin() ), .testTarget( @@ -67,9 +55,6 @@ let package = Package( .target(name: "XCTSpezi"), .product(name: "XCTRuntimeAssertions", package: "XCTRuntimeAssertions") ], - swiftSettings: [ - swiftConcurrency - ], plugins: [] + swiftLintPlugin() ) ] diff --git a/Sources/Spezi/Capabilities/ApplicationPropertyWrapper.swift b/Sources/Spezi/Capabilities/ApplicationPropertyWrapper.swift index f0aaad58..d6d33ad8 100644 --- a/Sources/Spezi/Capabilities/ApplicationPropertyWrapper.swift +++ b/Sources/Spezi/Capabilities/ApplicationPropertyWrapper.swift @@ -6,24 +6,29 @@ // SPDX-License-Identifier: MIT // +import SwiftUI -/// Refer to the documentation of ``Module/Application``. + +/// Access a property or action of the Spezi application. @propertyWrapper -public class _ApplicationPropertyWrapper { // swiftlint:disable:this type_name - private let keyPath: KeyPath +public struct _ApplicationPropertyWrapper { // swiftlint:disable:this type_name + private final class State { + weak var spezi: Spezi? + /// Some KeyPaths are declared to copy the value upon injection and not query them every time. + var shadowCopy: Value? + } - private weak var spezi: Spezi? - /// Some KeyPaths are declared to copy the value upon injection and not query them every time. - private var shadowCopy: Value? + private let keyPath: KeyPath + private let state = State() /// Access the application property. public var wrappedValue: Value { - if let shadowCopy { + if let shadowCopy = state.shadowCopy { return shadowCopy } - guard let spezi else { + guard let spezi = state.spezi else { preconditionFailure("Underlying Spezi instance was not yet injected. @Application cannot be accessed within the initializer!") } return spezi[keyPath: keyPath] @@ -39,15 +44,15 @@ public class _ApplicationPropertyWrapper { // swiftlint:disable:this type extension _ApplicationPropertyWrapper: SpeziPropertyWrapper { func inject(spezi: Spezi) { - self.spezi = spezi + state.spezi = spezi if spezi.createsCopy(keyPath) { - self.shadowCopy = spezi[keyPath: keyPath] + state.shadowCopy = spezi[keyPath: keyPath] } } func clear() { - spezi = nil - shadowCopy = nil + state.spezi = nil + state.shadowCopy = nil } } diff --git a/Sources/Spezi/Capabilities/Notifications/RegisterRemoteNotificationsAction.swift b/Sources/Spezi/Capabilities/Notifications/RegisterRemoteNotificationsAction.swift deleted file mode 100644 index 6375a5b4..00000000 --- a/Sources/Spezi/Capabilities/Notifications/RegisterRemoteNotificationsAction.swift +++ /dev/null @@ -1,242 +0,0 @@ -// -// 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 SpeziFoundation -import SwiftUI - - -@MainActor -private final class RemoteNotificationContinuation: KnowledgeSource, Sendable { - typealias Anchor = SpeziAnchor - - fileprivate(set) var continuation: CheckedContinuation? - fileprivate(set) var access = AsyncSemaphore() - - - init() {} - - - @MainActor - func resume(with result: Result) { - if let continuation { - self.continuation = nil - access.signal() - continuation.resume(with: result) - } - } -} - - -/// Registers to receive remote notifications through Apple Push Notification service. -/// -/// For more information refer to the [`registerForRemoteNotifications()`](https://developer.apple.com/documentation/uikit/uiapplication/1623078-registerforremotenotifications) -/// documentation for `UIApplication` or for the respective equivalent for your current platform. -/// -/// - Note: For more information on the general topic on how to register your app with APNs, -/// refer to the [Registering your app with APNs](https://developer.apple.com/documentation/usernotifications/registering-your-app-with-apns) -/// article. -/// > Tip: Make sure to request authorization by calling [`requestAuthorization(options:completionHandler:)`](https://developer.apple.com/documentation/usernotifications/unusernotificationcenter/requestauthorization(options:completionhandler:)) -/// to have your remote notifications be able to display alerts, badges or use sound. Otherwise, all remote notifications will be delivered silently. -/// -/// Below is a short code example on how to use this action within your ``Module``. -/// ```swift -/// class ExampleModule: Module { -/// @Application(\.registerRemoteNotifications) -/// var registerRemoteNotifications -/// -/// func handleNotificationsPermissions() async throws { -/// // Make sure to request notifications permissions before registering for remote notifications -/// try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) -/// let deviceToken = try await registerRemoteNotifications() -/// -/// // ... send the device token to your remote server that generates push notifications -/// } -/// } -/// ``` -/// -/// > Warning: The method might throw a [`TimeoutError`](https://swiftpackageindex.com/stanfordspezi/spezifoundation/documentation/spezifoundation/timeouterror) -/// if registering for remote notifications is not possible. -/// -/// Registering for Remote Notifications on Simulator devices might not be possible due to multiple reasons. -/// -/// #### Your application delegate, which is a subclass of SpeziAppDelegate, overrides some notification-related application delegate functions. -/// -/// **Solution:** Ensure that you correctly call the overridden method using super to pass all relevant information to Spezi. -/// -/// #### Your application does not have the correct entitlements and configuration in place to allow registering for remote notifications. -/// -/// **Solution:** Follow the [Apple Documentation](https://developer.apple.com/documentation/usernotifications/registering-your-app-with-apns). -/// -/// #### Your code or a dependency uses method swizzling, preventing the relevant methods in the application delegate from being called. -/// -/// **Solution:** Remove your method swizzling code or configure your dependency to disable this behavior. -/// For example, to [disable method swizzling in the iOS Firebase SDK](https://firebase.google.com/docs/cloud-messaging/ios/client#method_swizzling_in). -/// -/// #### The application is running in the iOS simulator on a Mac that is not signed into an Apple account (e.g., on a CI environment). -/// **Solution:** Sign in with an Apple account on your Mac and Xcode. For CI environments, use a special flag or compilation directive to catch the `TimeoutError`. -public struct RegisterRemoteNotificationsAction: Sendable { - private weak var spezi: Spezi? - - init(_ spezi: Spezi) { - self.spezi = spezi - } - - /// Registers to receive remote notifications through Apple Push Notification service. - /// - /// - Returns: A globally unique token that identifies this device to APNs. - /// Send this token to the server that you use to generate remote notifications. - /// Your server must pass this token unmodified back to APNs when sending those remote notifications. - /// For more information refer to the documentation of - /// [`application(_:didRegisterForRemoteNotificationsWithDeviceToken:)`](https://developer.apple.com/documentation/uikit/uiapplicationdelegate/1622958-application). - /// - Throws: Registration might fail if the user's device isn't connected to the network or - /// if your app is not properly configured for remote notifications. It might also throw a `TimeoutError` when running on a simulator device running on a host - /// that is not connected to an Apple ID. - @discardableResult - @MainActor - public func callAsFunction() async throws -> Data { - guard let spezi else { - preconditionFailure("RegisterRemoteNotificationsAction was used in a scope where Spezi was not available anymore!") - } - - -#if os(watchOS) - let application = _Application.shared() -#else - let application = _Application.shared -#endif // os(watchOS) - - let registration: RemoteNotificationContinuation - if let existing = spezi.storage[RemoteNotificationContinuation.self] { - registration = existing - } else { - registration = RemoteNotificationContinuation() - spezi.storage[RemoteNotificationContinuation.self] = registration - } - - try await registration.access.waitCheckingCancellation() - - async let _ = withTimeout(of: .seconds(5)) { @MainActor in - spezi.logger.warning( - """ - Registering for Remote Notifications Timed Out - - This issue can occur for several reasons: - - - Your application delegate (subclass of `SpeziAppDelegate`) overrides some notification-related application delegate functions. - Solution: Ensure that you correctly call the overridden method using `super` to pass all relevant information to Spezi. - - - Your application does not have the correct entitlements and configuration in place to allow registering for remote notifications. - Solution: Follow the Apple Documentation at https://developer.apple.com/documentation/usernotifications/registering-your-app-with-apns. - - - Your code or a dependency uses method swizzling, preventing the relevant methods in the application delegate from being called. - Solution: Remove your method swizzling code or configure your dependency to disable this behavior. - For example, to disable method swizzling in the iOS Firebase SDK, follow their guidelines at - https://firebase.google.com/docs/cloud-messaging/ios/client#method_swizzling_in. - - - The application is running in the iOS simulator on a Mac that is not signed into an Apple account (e.g., on a CI environment). - Solution: Sign in with an Apple account on your Mac and Xcode. - For CI environments, use a special flag or compilation directive to catch the `TimeoutError`. - """ - ) - spezi.storage[RemoteNotificationContinuation.self]?.resume(with: .failure(TimeoutError())) - } - - return try await withCheckedThrowingContinuation { continuation in - assert(registration.continuation == nil, "continuation wasn't nil") - registration.continuation = continuation - application.registerForRemoteNotifications() - } - } -} - - -extension Spezi { - /// Registers to receive remote notifications through Apple Push Notification service. - /// - /// For more information refer to the [`registerForRemoteNotifications()`](https://developer.apple.com/documentation/uikit/uiapplication/1623078-registerforremotenotifications) - /// documentation for `UIApplication` or for the respective equivalent for your current platform. - /// - /// - Note: For more information on the general topic on how to register your app with APNs, - /// refer to the [Registering your app with APNs](https://developer.apple.com/documentation/usernotifications/registering-your-app-with-apns) - /// article. - /// > Tip: Make sure to request authorization by calling [`requestAuthorization(options:completionHandler:)`](https://developer.apple.com/documentation/usernotifications/unusernotificationcenter/requestauthorization(options:completionhandler:)) - /// to have your remote notifications be able to display alerts, badges or use sound. Otherwise, all remote notifications will be delivered silently. - /// - /// Below is a short code example on how to use this action within your ``Module``. - /// ```swift - /// class ExampleModule: Module { - /// @Application(\.registerRemoteNotifications) - /// var registerRemoteNotifications - /// - /// func handleNotificationsPermissions() async throws { - /// // Make sure to request notifications permissions before registering for remote notifications - /// try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) - /// let deviceToken = try await registerRemoteNotifications() - /// - /// // ... send the device token to your remote server that generates push notifications - /// } - /// } - /// ``` - /// - /// > Warning: The method might throw a [`TimeoutError`](https://swiftpackageindex.com/stanfordspezi/spezifoundation/documentation/spezifoundation/timeouterror) - /// if registering for remote notifications is not possible. - /// - /// Registering for Remote Notifications on Simulator devices might not be possible due to multiple reasons. - /// - /// #### Your application delegate, which is a subclass of SpeziAppDelegate, overrides some notification-related application delegate functions. - /// - /// **Solution:** Ensure that you correctly call the overridden method using super to pass all relevant information to Spezi. - /// - /// #### Your application does not have the correct entitlements and configuration in place to allow registering for remote notifications. - /// - /// **Solution:** Follow the [Apple Documentation](https://developer.apple.com/documentation/usernotifications/registering-your-app-with-apns). - /// - /// #### Your code or a dependency uses method swizzling, preventing the relevant methods in the application delegate from being called. - /// - /// **Solution:** Remove your method swizzling code or configure your dependency to disable this behavior. - /// For example, to [disable method swizzling in the iOS Firebase SDK](https://firebase.google.com/docs/cloud-messaging/ios/client#method_swizzling_in). - /// - /// #### The application is running in the iOS simulator on a Mac that is not signed into an Apple account (e.g., on a CI environment). - /// **Solution:** Sign in with an Apple account on your Mac and Xcode. For CI environments, use a special flag or compilation directive to catch the `TimeoutError`. - /// - /// - /// ## Topics - /// ### Action - /// - ``RegisterRemoteNotificationsAction`` - public var registerRemoteNotifications: RegisterRemoteNotificationsAction { - RegisterRemoteNotificationsAction(self) - } -} - - -extension RegisterRemoteNotificationsAction { - @MainActor - static func handleDeviceTokenUpdate(_ spezi: Spezi, _ deviceToken: Data) { - guard let registration = spezi.storage[RemoteNotificationContinuation.self] else { - return - } - - // might also be called if, e.g., app is restored from backup and is automatically registered for remote notifications. - // This can be handled through the `NotificationHandler` protocol. - - registration.resume(with: .success(deviceToken)) - } - - @MainActor - static func handleFailedRegistration(_ spezi: Spezi, _ error: Error) { - guard let registration = spezi.storage[RemoteNotificationContinuation.self] else { - return - } - - if registration.continuation == nil { - spezi.logger.warning("Received a call to \(#function) while we were not waiting for a notifications registration request.") - } - - registration.resume(with: .failure(error)) - } -} diff --git a/Sources/Spezi/Dependencies/DependencyManager.swift b/Sources/Spezi/Dependencies/DependencyManager.swift index b821b63b..64589d90 100644 --- a/Sources/Spezi/Dependencies/DependencyManager.swift +++ b/Sources/Spezi/Dependencies/DependencyManager.swift @@ -59,12 +59,12 @@ public class DependencyManager: Sendable { /// Resolves the dependency order. /// /// After calling `resolve()` you can safely access `initializedModules`. - func resolve() { + func resolve() throws(DependencyManagerError) { while let nextModule = modulesWithDependencies.first { - push(nextModule) + try push(nextModule) } - injectDependencies() + try injectDependencies() assert(searchStacks.isEmpty, "`searchStacks` are not getting cleaned up!") assert(currentPushedModule == nil, "`currentPushedModule` is never reset!") assert(modulesWithDependencies.isEmpty, "modulesWithDependencies has remaining entries \(modulesWithDependencies)") @@ -109,18 +109,18 @@ public class DependencyManager: Sendable { return order } - private func injectDependencies() { + private func injectDependencies() throws(DependencyManagerError) { // We inject dependencies into existingModules as well as a new dependency might be an optional dependency from a existing module // that wasn't previously injected. for module in initializedModules + existingModules { for dependency in module.dependencyDeclarations { - dependency.inject(from: self, for: module) + try dependency.inject(from: self, for: module) } } } /// Push a module on the search stack and resolve dependency information. - private func push(_ module: any Module) { + private func push(_ module: any Module) throws(DependencyManagerError) { assert(currentPushedModule == nil, "Module already pushed. Did the algorithm turn into an recursive one by accident?") currentPushedModule = ModuleReference(module) @@ -128,7 +128,7 @@ public class DependencyManager: Sendable { .append(type(of: module)) for dependency in module.dependencyDeclarations { - dependency.collect(into: self) // leads to calls to `require(_:defaultValue:)` + try dependency.collect(into: self) // leads to calls to `require(_:defaultValue:)` } finishSearch(for: module) @@ -138,8 +138,8 @@ public class DependencyManager: Sendable { /// - Parameters: /// - dependency: The type of the dependency that should be resolved. /// - defaultValue: A default instance of the dependency that is used when the `dependencyType` is not present in the `initializedModules` or `modulesWithDependencies`. - func require(_ dependency: M.Type, type dependencyType: DependencyType, defaultValue: (() -> M)?) { - testForSearchStackCycles(M.self) + func require(_ dependency: M.Type, type dependencyType: DependencyType, defaultValue: (() -> M)?) throws(DependencyManagerError) { + try testForSearchStackCycles(M.self) // 1. Check if it is actively requested to load this module. if case .load = dependencyType { @@ -177,18 +177,13 @@ public class DependencyManager: Sendable { /// - module: The ``Module`` type to return. /// - optional: Flag indicating if it is a optional return. /// - Returns: Returns the Module instance. Only optional, if `optional` is set to `true` and no Module was found. - func retrieve(module: M.Type, type dependencyType: DependencyType, for owner: any Module) -> M? { + func retrieve(module: M.Type, type dependencyType: DependencyType, for owner: any Module) throws(DependencyManagerError) -> M? { guard let candidate = existingModules.first(where: { type(of: $0) == M.self }) ?? initializedModules.first(where: { type(of: $0) == M.self }), let module = candidate as? M else { - precondition( - dependencyType.isOptional, - """ - '\(type(of: owner)) requires dependency of type '\(M.self)' which wasn't configured. - Please make sure this module is configured by including it in the configuration of your `SpeziAppDelegate` or following \ - Module-specific instructions. - """ - ) + if !dependencyType.isOptional { + throw DependencyManagerError.missingRequiredModule(module: "\(type(of: owner))", requiredModule: "\(M.self)") + } return nil } @@ -231,20 +226,16 @@ public class DependencyManager: Sendable { searchStacks[ModuleReference(module)] = searchStack } - private func testForSearchStackCycles(_ module: M.Type) { + private func testForSearchStackCycles(_ module: M.Type) throws(DependencyManagerError) { if let currentPushedModule { let searchStack = searchStacks[currentPushedModule, default: []] - precondition( - !searchStack.contains(where: { $0 == M.self }), - """ - The `DependencyManager` has detected a dependency cycle of your Spezi modules. - The current dependency chain is: \(searchStack.map { String(describing: $0) }.joined(separator: ", ")). \ - The module '\(searchStack.last.unsafelyUnwrapped)' required '\(M.self)' which is contained in its own dependency chain. - - Please ensure that the modules you use or develop can not trigger a dependency cycle. - """ - ) + if searchStack.contains(where: { $0 == M.self }) { + let module = "\(searchStack.last.unsafelyUnwrapped)" + let dependencyChain = searchStack + .map { String(describing: $0) } + throw DependencyManagerError.searchStackCycle(module: module, requestedModule: "\(M.self)", dependencyChain: dependencyChain) + } } } } diff --git a/Sources/Spezi/Dependencies/DependencyManagerError.swift b/Sources/Spezi/Dependencies/DependencyManagerError.swift new file mode 100644 index 00000000..640d4155 --- /dev/null +++ b/Sources/Spezi/Dependencies/DependencyManagerError.swift @@ -0,0 +1,35 @@ +// +// 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 +// + + +enum DependencyManagerError: Error { + case searchStackCycle(module: String, requestedModule: String, dependencyChain: [String]) + case missingRequiredModule(module: String, requiredModule: String) +} + + +extension DependencyManagerError: CustomStringConvertible { + var description: String { + switch self { + case let .searchStackCycle(module, requestedModule, dependencyChain): + """ + The `DependencyManager` has detected a dependency cycle of your Spezi modules. + The current dependency chain is: \(dependencyChain.joined(separator: ", ")). \ + The module '\(module)' required '\(requestedModule)' which is contained in its own dependency chain. + + Please ensure that the modules you use or develop can not trigger a dependency cycle. + """ + case let .missingRequiredModule(module, requiredModule): + """ + '\(module) requires dependency of type '\(requiredModule)' which wasn't configured. + Please make sure this module is configured by including it in the configuration of your `SpeziAppDelegate` or following \ + Module-specific instructions. + """ + } + } +} diff --git a/Sources/Spezi/Dependencies/Property/DependencyCollection.swift b/Sources/Spezi/Dependencies/Property/DependencyCollection.swift index 16cca771..d911880e 100644 --- a/Sources/Spezi/Dependencies/Property/DependencyCollection.swift +++ b/Sources/Spezi/Dependencies/Property/DependencyCollection.swift @@ -127,15 +127,15 @@ extension DependencyCollection: DependencyDeclaration { } - func collect(into dependencyManager: DependencyManager) { + func collect(into dependencyManager: DependencyManager) throws(DependencyManagerError) { for entry in entries { - entry.collect(into: dependencyManager) + try entry.collect(into: dependencyManager) } } - func inject(from dependencyManager: DependencyManager, for module: any Module) { + func inject(from dependencyManager: DependencyManager, for module: any Module) throws(DependencyManagerError) { for entry in entries { - entry.inject(from: dependencyManager, for: module) + try entry.inject(from: dependencyManager, for: module) } } diff --git a/Sources/Spezi/Dependencies/Property/DependencyContext.swift b/Sources/Spezi/Dependencies/Property/DependencyContext.swift index 041ff82d..8c0e0bb1 100644 --- a/Sources/Spezi/Dependencies/Property/DependencyContext.swift +++ b/Sources/Spezi/Dependencies/Property/DependencyContext.swift @@ -92,12 +92,12 @@ class DependencyContext: AnyDependencyContext { } } - func collect(into dependencyManager: DependencyManager) { - dependencyManager.require(Dependency.self, type: type, defaultValue: defaultValue) + func collect(into dependencyManager: DependencyManager) throws(DependencyManagerError) { + try dependencyManager.require(Dependency.self, type: type, defaultValue: defaultValue) } - func inject(from dependencyManager: DependencyManager, for module: any Module) { - guard let dependency = dependencyManager.retrieve(module: Dependency.self, type: type, for: module) else { + func inject(from dependencyManager: DependencyManager, for module: any Module) throws(DependencyManagerError) { + guard let dependency = try dependencyManager.retrieve(module: Dependency.self, type: type, for: module) else { injectedDependency = nil return } diff --git a/Sources/Spezi/Dependencies/Property/DependencyDeclaration.swift b/Sources/Spezi/Dependencies/Property/DependencyDeclaration.swift index 13a942f8..87ea0779 100644 --- a/Sources/Spezi/Dependencies/Property/DependencyDeclaration.swift +++ b/Sources/Spezi/Dependencies/Property/DependencyDeclaration.swift @@ -28,13 +28,13 @@ protocol DependencyDeclaration { /// Request from the ``DependencyManager`` to collect all dependencies. Mark required by calling `DependencyManager/require(_:defaultValue:)`. @MainActor - func collect(into dependencyManager: DependencyManager) + func collect(into dependencyManager: DependencyManager) throws(DependencyManagerError) /// Inject the dependency instance from the ``DependencyManager``. Use `DependencyManager/retrieve(module:)`. /// - Parameters: /// - dependencyManager: The dependency manager to inject the dependencies from. /// - module: The module where the dependency declaration is located at. @MainActor - func inject(from dependencyManager: DependencyManager, for module: any Module) + func inject(from dependencyManager: DependencyManager, for module: any Module) throws(DependencyManagerError) @MainActor func inject(spezi: Spezi) diff --git a/Sources/Spezi/Dependencies/Property/DependencyPropertyWrapper.swift b/Sources/Spezi/Dependencies/Property/DependencyPropertyWrapper.swift index 417eb1da..4f0be72c 100644 --- a/Sources/Spezi/Dependencies/Property/DependencyPropertyWrapper.swift +++ b/Sources/Spezi/Dependencies/Property/DependencyPropertyWrapper.swift @@ -118,12 +118,12 @@ extension _DependencyPropertyWrapper: DependencyDeclaration { dependencies.dependencyRelation(to: module) } - func collect(into dependencyManager: DependencyManager) { - dependencies.collect(into: dependencyManager) + func collect(into dependencyManager: DependencyManager) throws(DependencyManagerError) { + try dependencies.collect(into: dependencyManager) } - func inject(from dependencyManager: DependencyManager, for module: any Module) { - dependencies.inject(from: dependencyManager, for: module) + func inject(from dependencyManager: DependencyManager, for module: any Module) throws(DependencyManagerError) { + try dependencies.inject(from: dependencyManager, for: module) } func uninjectDependencies(notifying spezi: Spezi) { diff --git a/Sources/Spezi/Utilities/BackgroundFetchResult.swift b/Sources/Spezi/Notifications/BackgroundFetchResult.swift similarity index 100% rename from Sources/Spezi/Utilities/BackgroundFetchResult.swift rename to Sources/Spezi/Notifications/BackgroundFetchResult.swift diff --git a/Sources/Spezi/Capabilities/Notifications/NotificationHandler.swift b/Sources/Spezi/Notifications/NotificationHandler.swift similarity index 100% rename from Sources/Spezi/Capabilities/Notifications/NotificationHandler.swift rename to Sources/Spezi/Notifications/NotificationHandler.swift diff --git a/Sources/Spezi/Capabilities/Notifications/NotificationTokenHandler.swift b/Sources/Spezi/Notifications/NotificationTokenHandler.swift similarity index 100% rename from Sources/Spezi/Capabilities/Notifications/NotificationTokenHandler.swift rename to Sources/Spezi/Notifications/NotificationTokenHandler.swift diff --git a/Sources/Spezi/Notifications/RemoteNotificationRegistrationSupport.swift b/Sources/Spezi/Notifications/RemoteNotificationRegistrationSupport.swift new file mode 100644 index 00000000..81b0c6a6 --- /dev/null +++ b/Sources/Spezi/Notifications/RemoteNotificationRegistrationSupport.swift @@ -0,0 +1,90 @@ +// +// 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 OSLog +import SpeziFoundation +import SwiftUI + + +@MainActor +@_spi(APISupport) +public final class RemoteNotificationRegistrationSupport: KnowledgeSource, Sendable { + public typealias Anchor = SpeziAnchor + + private let logger = Logger(subsystem: "edu.stanford.spezi", category: "RemoteNotificationRegistrationSupport") + + fileprivate(set) var continuation: CheckedContinuation? + fileprivate(set) var access = AsyncSemaphore() + + + nonisolated init() {} + + + func handleDeviceTokenUpdate(_ deviceToken: Data) { + // might also be called if, e.g., app is restored from backup and is automatically registered for remote notifications. + // This can be handled through the `NotificationHandler` protocol. + + resume(with: .success(deviceToken)) + } + + func handleFailedRegistration(_ error: Error) { + let resumed = resume(with: .failure(error)) + + if !resumed { + logger.warning("Received a call to \(#function) while we were not waiting for a notifications registration request.") + } + } + + + @discardableResult + private func resume(with result: Result) -> Bool { + if let continuation { + self.continuation = nil + access.signal() + continuation.resume(with: result) + return true + } + return false + } + + public func callAsFunction() async throws -> Data { + try await access.waitCheckingCancellation() + +#if targetEnvironment(simulator) + async let _ = withTimeout(of: .seconds(5)) { @MainActor in + logger.warning("Registering for remote notifications seems to be not possible on this simulator device. Timing out ...") + self.continuation?.resume(with: .failure(TimeoutError())) + } +#endif + + return try await withCheckedThrowingContinuation { continuation in + assert(self.continuation == nil, "continuation wasn't nil") + self.continuation = continuation + _Application.shared.registerForRemoteNotifications() + } + } +} + + +extension Spezi { + /// Provides support to call the `registerForRemoteNotifications()` method on the application. + /// + /// This helper type makes sure to bridge access to the delegate methods that will be called when executing `registerForRemoteNotifications()`. + @MainActor + @_spi(APISupport) + public var remoteNotificationRegistrationSupport: RemoteNotificationRegistrationSupport { + let support: RemoteNotificationRegistrationSupport + if let existing = spezi.storage[RemoteNotificationRegistrationSupport.self] { + support = existing + } else { + support = RemoteNotificationRegistrationSupport() + spezi.storage[RemoteNotificationRegistrationSupport.self] = support + } + return support + } +} diff --git a/Sources/Spezi/Notifications/Spezi+RegisterRemoteNotificationsAction.swift b/Sources/Spezi/Notifications/Spezi+RegisterRemoteNotificationsAction.swift new file mode 100644 index 00000000..62793253 --- /dev/null +++ b/Sources/Spezi/Notifications/Spezi+RegisterRemoteNotificationsAction.swift @@ -0,0 +1,106 @@ +// +// 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 SpeziFoundation +import SwiftUI + + +/// Registers to receive remote notifications through Apple Push Notification service. +/// +/// Refer to the documentation of ``Spezi/registerRemoteNotifications``. +@_documentation(visibility: internal) +@available(*, deprecated, renamed: "Spezi.RegisterRemoteNotificationsAction", message: "Please use Spezi.RegisterRemoteNotificationsAction instead.") +public typealias RegisterRemoteNotificationsAction = Spezi.RegisterRemoteNotificationsAction + + +extension Spezi { + /// Registers to receive remote notifications through Apple Push Notification service. + /// + /// Refer to the documentation of ``Spezi/registerRemoteNotifications``. + public struct RegisterRemoteNotificationsAction: Sendable { + private weak var spezi: Spezi? + + fileprivate init(_ spezi: Spezi) { + self.spezi = spezi + } + + /// Registers to receive remote notifications through Apple Push Notification service. + /// + /// - Returns: A globally unique token that identifies this device to APNs. + /// Send this token to the server that you use to generate remote notifications. + /// Your server must pass this token unmodified back to APNs when sending those remote notifications. + /// For more information refer to the documentation of + /// [`application(_:didRegisterForRemoteNotificationsWithDeviceToken:)`](https://developer.apple.com/documentation/uikit/uiapplicationdelegate/1622958-application). + /// - Throws: Registration might fail if the user's device isn't connected to the network or + /// if your app is not properly configured for remote notifications. It might also throw a `TimeoutError` when running on a simulator device running on a host + /// that is not connected to an Apple ID. + @discardableResult + @MainActor + public func callAsFunction() async throws -> Data { + guard let spezi else { + preconditionFailure("\(Self.self) was used in a scope where Spezi was not available anymore!") + } + + return try await spezi.remoteNotificationRegistrationSupport() + } + } + + /// Registers to receive remote notifications through Apple Push Notification service. + /// + /// For more information refer to the [`registerForRemoteNotifications()`](https://developer.apple.com/documentation/uikit/uiapplication/1623078-registerforremotenotifications) + /// documentation for `UIApplication` or for the respective equivalent for your current platform. + /// + /// - Note: For more information on the general topic on how to register your app with APNs, + /// refer to the [Registering your app with APNs](https://developer.apple.com/documentation/usernotifications/registering-your-app-with-apns) + /// article. + /// + /// Below is a short code example on how to use this action within your ``Module``. + /// + /// - Warning: Registering for Remote Notifications on Simulator devices might not be possible if your are not signed into an Apple ID on the host machine. + /// The method might throw a [`TimeoutError`](https://swiftpackageindex.com/stanfordspezi/spezifoundation/documentation/spezifoundation/timeouterror) + /// in such a case. + /// + /// ```swift + /// import SpeziFoundation + /// + /// class ExampleModule: Module { + /// @Application(\.registerRemoteNotifications) + /// var registerRemoteNotifications + /// + /// func handleNotificationsPermissions() async throws { + /// // Make sure to request notifications permissions before registering for remote notifications + /// try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) + /// + /// + /// do { + /// let deviceToken = try await registerRemoteNotifications() + /// } catch let error as TimeoutError { + /// #if targetEnvironment(simulator) + /// return // override logic when running within a simulator + /// #else + /// throw error + /// #endif + /// } + /// + /// // .. send the device token to your remote server that generates push notifications + /// } + /// } + /// ``` + /// + /// > Tip: Make sure to request authorization by calling [`requestAuthorization(options:completionHandler:)`](https://developer.apple.com/documentation/usernotifications/unusernotificationcenter/requestauthorization(options:completionhandler:)) + /// to have your remote notifications be able to display alerts, badges or use sound. Otherwise, all remote notifications will be delivered silently. + /// + /// ## Topics + /// ### Action + /// - ``RegisterRemoteNotificationsAction`` + @_disfavoredOverload + @available(*, deprecated, message: "Please migrate to the new SpeziNotifications package: https://github.com/StanfordSpezi/SpeziNotifications") + public var registerRemoteNotifications: RegisterRemoteNotificationsAction { + RegisterRemoteNotificationsAction(self) + } +} diff --git a/Sources/Spezi/Capabilities/Notifications/UnregisterRemoteNotificationsAction.swift b/Sources/Spezi/Notifications/Spezi+UnregisterRemoteNotifications.swift similarity index 58% rename from Sources/Spezi/Capabilities/Notifications/UnregisterRemoteNotificationsAction.swift rename to Sources/Spezi/Notifications/Spezi+UnregisterRemoteNotifications.swift index 7b1bebea..023fd5b0 100644 --- a/Sources/Spezi/Capabilities/Notifications/UnregisterRemoteNotificationsAction.swift +++ b/Sources/Spezi/Notifications/Spezi+UnregisterRemoteNotifications.swift @@ -11,41 +11,28 @@ import SwiftUI /// Unregisters for all remote notifications received through Apple Push Notification service. /// -/// For more information refer to the [`unregisterForRemoteNotifications()`](https://developer.apple.com/documentation/uikit/uiapplication/1623093-unregisterforremotenotifications) -/// documentation for `UIApplication` or for the respective equivalent for your current platform. -/// -/// Below is a short code example on how to use this action within your ``Module``. -/// -/// ```swift -/// class ExampleModule: Module { -/// @Application(\.unregisterRemoteNotifications) -/// var unregisterRemoteNotifications -/// -/// func onAccountLogout() { -/// // handling your cleanup ... -/// unregisterRemoteNotifications() -/// } -/// } -/// ``` -public struct UnregisterRemoteNotificationsAction: Sendable { - init() {} +/// Refer to the documentation of ``Spezi/unregisterRemoteNotifications``. +@_documentation(visibility: internal) +@available(*, deprecated, renamed: "Spezi.UnregisterRemoteNotificationsAction", message: "Please use Spezi.UnregisterRemoteNotificationsAction") +public typealias UnregisterRemoteNotificationsAction = Spezi.UnregisterRemoteNotificationsAction +extension Spezi { /// Unregisters for all remote notifications received through Apple Push Notification service. - @MainActor - public func callAsFunction() { -#if os(watchOS) - let application = _Application.shared() -#else - let application = _Application.shared -#endif - - application.unregisterForRemoteNotifications() - } -} + /// + /// Refer to the documentation of ``Spezi/unregisterRemoteNotifications``. + @available(*, deprecated, message: "Please migrate to the new SpeziNotifications package: https://github.com/StanfordSpezi/SpeziNotifications") + public struct UnregisterRemoteNotificationsAction: Sendable { + fileprivate init() {} -extension Spezi { + /// Unregisters for all remote notifications received through Apple Push Notification service. + @MainActor + public func callAsFunction() { + _Application.shared.unregisterForRemoteNotifications() + } + } + /// Unregisters for all remote notifications received through Apple Push Notification service. /// /// For more information refer to the [`unregisterForRemoteNotifications()`](https://developer.apple.com/documentation/uikit/uiapplication/1623093-unregisterforremotenotifications) @@ -68,6 +55,8 @@ extension Spezi { /// ## Topics /// ### Action /// - ``UnregisterRemoteNotificationsAction`` + @_disfavoredOverload + @available(*, deprecated, message: "Please migrate to the new SpeziNotifications package: https://github.com/StanfordSpezi/SpeziNotifications") public var unregisterRemoteNotifications: UnregisterRemoteNotificationsAction { UnregisterRemoteNotificationsAction() } diff --git a/Sources/Spezi/Spezi/SpeziNotificationCenterDelegate.swift b/Sources/Spezi/Notifications/SpeziNotificationCenterDelegate.swift similarity index 85% rename from Sources/Spezi/Spezi/SpeziNotificationCenterDelegate.swift rename to Sources/Spezi/Notifications/SpeziNotificationCenterDelegate.swift index d23464e3..bc62e5d7 100644 --- a/Sources/Spezi/Spezi/SpeziNotificationCenterDelegate.swift +++ b/Sources/Spezi/Notifications/SpeziNotificationCenterDelegate.swift @@ -20,10 +20,7 @@ class SpeziNotificationCenterDelegate: NSObject { // The completion handler would also be called on a background thread which results in a crash. // Declaring the method as @MainActor requires a @preconcurrency inheritance from the delegate to silence Sendable warnings. - 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. + await withTaskGroup(of: Void.self) { group in guard let delegate = SpeziAppDelegate.appDelegate else { return } @@ -44,8 +41,7 @@ class SpeziNotificationCenterDelegate: NSObject { _ center: UNUserNotificationCenter, willPresent notification: UNNotification ) async -> UNNotificationPresentationOptions { - await withTaskGroup(of: UNNotificationPresentationOptions?.self) { @MainActor group in - // See comment in method above. + await withTaskGroup(of: UNNotificationPresentationOptions?.self) { group in guard let delegate = SpeziAppDelegate.appDelegate else { return [] } diff --git a/Sources/Spezi/Spezi.docc/Interactions with Application.md b/Sources/Spezi/Spezi.docc/Interactions with Application.md new file mode 100644 index 00000000..ebaa68b9 --- /dev/null +++ b/Sources/Spezi/Spezi.docc/Interactions with Application.md @@ -0,0 +1,42 @@ +# Interactions with Application + +Interact with the Application. + + + +## Overview + +Spezi provides platform-agnostic mechanisms to interact with your application instance. +To access application properties or actions you can use the ``Module/Application`` property wrapper within your +``Module`` or ``Standard``. + +> Tip: The articles illustrates how you can easily manage user notifications within your Spezi application. + +## Topics + +### Application Interaction + +- ``Module/Application`` + +### Properties + +- ``Spezi/logger`` +- ``Spezi/launchOptions`` + +### Notifications + +- ``Spezi/registerRemoteNotifications`` +- ``Spezi/unregisterRemoteNotifications`` + +### Platform-agnostic type-aliases + +- ``ApplicationDelegateAdaptor`` +- ``BackgroundFetchResult`` diff --git a/Sources/Spezi/Spezi.docc/Module/Interactions with Application.md b/Sources/Spezi/Spezi.docc/Module/Interactions with Application.md index be9b9de4..ebaa68b9 100644 --- a/Sources/Spezi/Spezi.docc/Module/Interactions with Application.md +++ b/Sources/Spezi/Spezi.docc/Module/Interactions with Application.md @@ -31,7 +31,7 @@ To access application properties or actions you can use the ``Module/Application - ``Spezi/logger`` - ``Spezi/launchOptions`` -### Actions +### Notifications - ``Spezi/registerRemoteNotifications`` - ``Spezi/unregisterRemoteNotifications`` diff --git a/Sources/Spezi/Spezi.docc/Module/Notifications.md b/Sources/Spezi/Spezi.docc/Module/Notifications.md index 0dd3d0e0..ecabc70e 100644 --- a/Sources/Spezi/Spezi.docc/Module/Notifications.md +++ b/Sources/Spezi/Spezi.docc/Module/Notifications.md @@ -60,9 +60,10 @@ implement the ``NotificationHandler/receiveRemoteNotification(_:)`` method. ### Notifications - ``NotificationHandler`` -- ``NotificationTokenHandler`` -### Remote Notification Registration +### Apple Push Notification Service +- ``NotificationTokenHandler`` - ``Spezi/registerRemoteNotifications`` - ``Spezi/unregisterRemoteNotifications`` + diff --git a/Sources/Spezi/Spezi.docc/Spezi.md b/Sources/Spezi/Spezi.docc/Spezi.md index 66116d71..1b4b90cd 100644 --- a/Sources/Spezi/Spezi.docc/Spezi.md +++ b/Sources/Spezi/Spezi.docc/Spezi.md @@ -92,7 +92,7 @@ You can learn more about modules in the documentation. - - ``SpeziAppDelegate`` - ``Configuration`` -- ``SwiftUI/View/spezi(_:)-3bn89`` +- ``SwiftUICore/View/spezi(_:)-3bn89`` ### Essential Concepts @@ -102,8 +102,8 @@ You can learn more about modules in the documentation. ### Previews -- ``SwiftUI/View/previewWith(standard:simulateLifecycle:_:)`` -- ``SwiftUI/View/previewWith(simulateLifecycle:_:)`` +- ``SwiftUICore/View/previewWith(standard:simulateLifecycle:_:)`` +- ``SwiftUICore/View/previewWith(simulateLifecycle:_:)`` - ``Foundation/ProcessInfo/isPreviewSimulator`` - ``LifecycleSimulationOptions`` diff --git a/Sources/Spezi/Spezi/Spezi+Preview.swift b/Sources/Spezi/Spezi/Spezi+Preview.swift index 69a81698..c28d6456 100644 --- a/Sources/Spezi/Spezi/Spezi+Preview.swift +++ b/Sources/Spezi/Spezi/Spezi+Preview.swift @@ -11,6 +11,17 @@ import SwiftUI import XCTRuntimeAssertions +#if os(iOS) || os(visionOS) || os(tvOS) +/// Protocol used to silence deprecation warnings. +@_spi(Internal) +public protocol DeprecatedLaunchOptionsCall { + /// Forward to legacy lifecycle handlers. + @MainActor + func callWillFinishLaunching(_ application: UIApplication, launchOptions: [UIApplication.LaunchOptionsKey: Any]) +} +#endif + + /// Options to simulate behavior for a ``LifecycleHandler`` in cases where there is no app delegate like in Preview setups. @MainActor public enum LifecycleSimulationOptions { @@ -31,6 +42,17 @@ public enum LifecycleSimulationOptions { } +#if os(iOS) || os(visionOS) || os(tvOS) +@_spi(Internal) +extension Spezi: DeprecatedLaunchOptionsCall { + @available(*, deprecated, message: "Propagate deprecation warning.") + public func callWillFinishLaunching(_ application: UIApplication, launchOptions: [UIApplication.LaunchOptionsKey: Any]) { + lifecycleHandler.willFinishLaunchingWithOptions(application, launchOptions: launchOptions) + } +} +#endif + + extension View { /// Configure Spezi for your previews using a Standard and a collection of Modules. /// @@ -62,13 +84,13 @@ extension View { } let spezi = Spezi(standard: standard, modules: modules().elements, storage: storage) - let lifecycleHandlers = spezi.lifecycleHandler return modifier(SpeziViewModifier(spezi)) #if os(iOS) || os(visionOS) || os(tvOS) .task { @MainActor in if case let .launchWithOptions(options) = simulateLifecycle { - lifecycleHandlers.willFinishLaunchingWithOptions(UIApplication.shared, launchOptions: options) + (spezi as DeprecatedLaunchOptionsCall) + .callWillFinishLaunching(UIApplication.shared, launchOptions: options) } } #endif diff --git a/Sources/Spezi/Spezi/Spezi.swift b/Sources/Spezi/Spezi/Spezi.swift index 22f01052..2e90aa50 100644 --- a/Sources/Spezi/Spezi/Spezi.swift +++ b/Sources/Spezi/Spezi/Spezi.swift @@ -90,8 +90,6 @@ import XCTRuntimeAssertions public final class Spezi: Sendable { static let logger = Logger(subsystem: "edu.stanford.spezi", category: "Spezi") - @TaskLocal static var moduleInitContext: ModuleDescription? - let standard: any Standard /// A shared repository to store any `KnowledgeSource`s restricted to the ``SpeziAnchor``. @@ -188,9 +186,13 @@ public final class Spezi: Sendable { self.standard = standard self.storage = consume storage - self.loadModules(modules, ownership: .spezi) - // load standard separately, such that all module loading takes precedence - self.loadModule(standard, ownership: .spezi) + do { + try self.loadModules(modules, ownership: .spezi) + // load standard separately, such that all module loading takes precedence + try self.loadModules([standard], ownership: .spezi) + } catch { + preconditionFailure(error.description) + } } /// Load a new Module. @@ -211,11 +213,15 @@ public final class Spezi: Sendable { /// - ``ModuleOwnership`` @MainActor public func loadModule(_ module: any Module, ownership: ModuleOwnership = .spezi) { - loadModules([module], ownership: ownership) + do { + try loadModules([module], ownership: ownership) + } catch { + preconditionFailure(error.description) + } } @MainActor - private func loadModules(_ modules: [any Module], ownership: ModuleOwnership) { + func loadModules(_ modules: [any Module], ownership: ModuleOwnership) throws(SpeziModuleError) { precondition(Self.moduleInitContext == nil, "Modules cannot be loaded within the `configure()` method.") purgeWeaklyReferenced() @@ -227,13 +233,17 @@ public final class Spezi: Sendable { let existingModules = self.modules let dependencyManager = DependencyManager(modules, existing: existingModules) - dependencyManager.resolve() + do { + try dependencyManager.resolve() + } catch { + throw .dependency(error) + } implicitlyCreatedModules.formUnion(dependencyManager.implicitlyCreatedModules) // we pass through the whole list of modules once to collect all @Provide values for module in dependencyManager.initializedModules { - Self.$moduleInitContext.withValue(module.moduleDescription) { + withModuleInitContext(module.moduleDescription) { module.collectModuleValues(into: &storage) } } @@ -241,9 +251,9 @@ public final class Spezi: Sendable { for module in dependencyManager.initializedModules { if requestedModules.contains(ModuleReference(module)) { // the policy only applies to the requested modules, all other are always managed and owned by Spezi - self.initModule(module, ownership: ownership) + try self.initModule(module, ownership: ownership) } else { - self.initModule(module, ownership: .spezi) + try self.initModule(module, ownership: .spezi) } } @@ -265,6 +275,15 @@ public final class Spezi: Sendable { /// - Parameter module: The Module to unload. @MainActor public func unloadModule(_ module: any Module) { + do { + try _unloadModule(module) + } catch { + preconditionFailure(error.description) + } + } + + @MainActor + func _unloadModule(_ module: any Module) throws(SpeziModuleError) { // swiftlint:disable:this identifier_name precondition(Self.moduleInitContext == nil, "Modules cannot be unloaded within the `configure()` method.") purgeWeaklyReferenced() @@ -276,7 +295,9 @@ public final class Spezi: Sendable { logger.debug("Unloading module \(type(of: module)) ...") let dependents = retrieveDependingModules(module.dependencyReference, considerOptionals: false) - precondition(dependents.isEmpty, "Tried to unload Module \(type(of: module)) that is still required by peer Modules: \(dependents)") + if !dependents.isEmpty { + throw SpeziModuleError.moduleStillRequired(module: "\(type(of: module))", dependents: dependents.map { "\(type(of: $0))" }) + } module.clearModule(from: self) @@ -296,7 +317,11 @@ public final class Spezi: Sendable { // re-injecting all dependencies ensures that the unloaded module is cleared from optional Dependencies from // pre-existing Modules. let dependencyManager = DependencyManager([], existing: modules) - dependencyManager.resolve() + do { + try dependencyManager.resolve() + } catch { + preconditionFailure("Internal inconsistency. Repeated dependency resolve resulted in error: \(error)") + } module.clear() // automatically removes @Provide values and recursively unloads implicitly created modules } @@ -309,38 +334,42 @@ public final class Spezi: Sendable { /// - module: The module to initialize. /// - ownership: Define the type of ownership when loading the module. @MainActor - private func initModule(_ module: any Module, ownership: ModuleOwnership) { + private func initModule(_ module: any Module, ownership: ModuleOwnership) throws(SpeziModuleError) { precondition(!module.isLoaded(in: self), "Tried to initialize Module \(type(of: module)) that was already loaded!") - Self.$moduleInitContext.withValue(module.moduleDescription) { - module.inject(spezi: self) + do { + try withModuleInitContext(module.moduleDescription) { () throws(SpeziPropertyError) in + try module.inject(spezi: self) - // supply modules values to all @Collect - module.injectModuleValues(from: storage) + // supply modules values to all @Collect + module.injectModuleValues(from: storage) - module.configure() + module.configure() - switch ownership { - case .spezi: - module.storeModule(into: self) - case .external: - module.storeWeakly(into: self) - } + switch ownership { + case .spezi: + module.storeModule(into: self) + case .external: + module.storeWeakly(into: self) + } - // If a module is @Observable, we automatically inject it view the `ModelModifier` into the environment. - 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 - } + // If a module is @Observable, we automatically inject it view the `ModelModifier` into the environment. + 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 + } - let modifierEntires: [(id: UUID, modifier: any ViewModifier)] = module.viewModifierEntires - // this check is important. Change to viewModifiers re-renders the whole SwiftUI view hierarchy. So avoid to do it unnecessarily - if !modifierEntires.isEmpty { - for entry in modifierEntires.reversed() { // reversed, as we re-reverse things in the `viewModifier` getter - _viewModifiers.updateValue(entry.modifier, forKey: entry.id) + let modifierEntires: [(id: UUID, modifier: any ViewModifier)] = module.viewModifierEntires + // this check is important. Change to viewModifiers re-renders the whole SwiftUI view hierarchy. So avoid to do it unnecessarily + if !modifierEntires.isEmpty { + for entry in modifierEntires.reversed() { // reversed, as we re-reverse things in the `viewModifier` getter + _viewModifiers.updateValue(entry.modifier, forKey: entry.id) + } } } + } catch { + throw .property(error) } } @@ -475,3 +504,34 @@ extension Module { } } } + + +extension Spezi { + private static let initContextLock = NSLock() + private static nonisolated(unsafe) var _moduleInitContext: ModuleDescription? + + private(set) static var moduleInitContext: ModuleDescription? { + get { + initContextLock.withLock { + _moduleInitContext + } + } + set { + initContextLock.withLock { + _moduleInitContext = newValue + } + } + } + + @MainActor + func withModuleInitContext(_ context: ModuleDescription, perform action: () throws(E) -> Void) throws(E) { + Self.moduleInitContext = context + defer { + Self.moduleInitContext = nil + } + + try action() + } +} + +// swiftlint:disable:this file_length diff --git a/Sources/Spezi/Spezi/SpeziAppDelegate.swift b/Sources/Spezi/Spezi/SpeziAppDelegate.swift index a4c5a4f1..f000e1b0 100644 --- a/Sources/Spezi/Spezi/SpeziAppDelegate.swift +++ b/Sources/Spezi/Spezi/SpeziAppDelegate.swift @@ -53,7 +53,17 @@ open class SpeziAppDelegate: NSObject, ApplicationDelegate, Sendable { private(set) static weak var appDelegate: SpeziAppDelegate? static var notificationDelegate: SpeziNotificationCenterDelegate? // swiftlint:disable:this weak_delegate - private var _spezi: Spezi? + /// Access the Spezi instance. + /// + /// Use this property as a basis for creating your own APIs (e.g., providing SwiftUI Environment values that use information from Spezi). + /// To not make it directly available to the user. + @_spi(APISupport) + public static var spezi: Spezi? { + SpeziAppDelegate.appDelegate?._spezi + } + + private(set) var _spezi: Spezi? // swiftlint:disable:this identifier_name + var spezi: Spezi { guard let spezi = _spezi else { @@ -63,8 +73,8 @@ open class SpeziAppDelegate: NSObject, ApplicationDelegate, Sendable { } return spezi } - - + + /// Register your different ``Module``s (or more sophisticated ``Module``s) using the ``SpeziAppDelegate/configuration`` property,. /// /// The ``Standard`` acts as a central message broker in the application. @@ -142,7 +152,7 @@ open class SpeziAppDelegate: NSObject, ApplicationDelegate, Sendable { open func application(_ application: _Application, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { MainActor.assumeIsolated { // on macOS there is a missing MainActor annotation - RegisterRemoteNotificationsAction.handleDeviceTokenUpdate(spezi, deviceToken) + spezi.remoteNotificationRegistrationSupport.handleDeviceTokenUpdate(deviceToken) // notify all notification handlers of an updated token for handler in spezi.notificationTokenHandler { @@ -153,7 +163,7 @@ open class SpeziAppDelegate: NSObject, ApplicationDelegate, Sendable { open func application(_ application: _Application, didFailToRegisterForRemoteNotificationsWithError error: Error) { MainActor.assumeIsolated { // on macOS there is a missing MainActor annotation - RegisterRemoteNotificationsAction.handleFailedRegistration(spezi, error) + spezi.remoteNotificationRegistrationSupport.handleFailedRegistration(error) } } diff --git a/Sources/Spezi/Spezi/SpeziModuleError.swift b/Sources/Spezi/Spezi/SpeziModuleError.swift new file mode 100644 index 00000000..936b5a1b --- /dev/null +++ b/Sources/Spezi/Spezi/SpeziModuleError.swift @@ -0,0 +1,26 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + + +enum SpeziModuleError: Error, CustomStringConvertible { + case dependency(DependencyManagerError) + case property(SpeziPropertyError) + + case moduleStillRequired(module: String, dependents: [String]) + + var description: String { + switch self { + case let .dependency(error): + error.description + case let .property(error): + error.description + case let .moduleStillRequired(module, dependents): + "Tried to unload Module \(type(of: module)) that is still required by peer Module(s): \(dependents.joined(separator: ", "))" + } + } +} diff --git a/Sources/Spezi/Spezi/SpeziPropertyWrapper.swift b/Sources/Spezi/Spezi/SpeziPropertyWrapper.swift index 5060448e..da944b48 100644 --- a/Sources/Spezi/Spezi/SpeziPropertyWrapper.swift +++ b/Sources/Spezi/Spezi/SpeziPropertyWrapper.swift @@ -7,6 +7,11 @@ // +enum SpeziPropertyError: Error { + case unsatisfiedStandardConstraint(constraint: String, standard: String) +} + + protocol SpeziPropertyWrapper { /// Inject the global Spezi instance. /// @@ -14,7 +19,7 @@ protocol SpeziPropertyWrapper { /// An empty default implementation is provided. /// - Parameter spezi: The global ``Spezi/Spezi`` instance. @MainActor - func inject(spezi: Spezi) + func inject(spezi: Spezi) throws(SpeziPropertyError) /// Clear the property wrapper state before the Module is unloaded. @MainActor @@ -29,9 +34,9 @@ extension SpeziPropertyWrapper { extension Module { @MainActor - func inject(spezi: Spezi) { + func inject(spezi: Spezi) throws(SpeziPropertyError) { for wrapper in retrieveProperties(ofType: SpeziPropertyWrapper.self) { - wrapper.inject(spezi: spezi) + try wrapper.inject(spezi: spezi) } } @@ -42,3 +47,32 @@ extension Module { } } } + + +extension SpeziPropertyError: CustomStringConvertible { + var description: String { + switch self { + case let .unsatisfiedStandardConstraint(constraint, standard): + """ + The `Standard` defined in the `Configuration` does not conform to \(constraint). + + Ensure that you define an appropriate standard in your configuration in your `SpeziAppDelegate` subclass ... + ``` + var configuration: Configuration { + Configuration(standard: \(standard)()) { + // ... + } + } + ``` + + ... and that your standard conforms to \(constraint): + + ```swift + actor \(standard): Standard, \(constraint) { + // ... + } + ``` + """ + } + } +} diff --git a/Sources/Spezi/Standard/StandardPropertyWrapper.swift b/Sources/Spezi/Standard/StandardPropertyWrapper.swift index 7b3669c0..eae08123 100644 --- a/Sources/Spezi/Standard/StandardPropertyWrapper.swift +++ b/Sources/Spezi/Standard/StandardPropertyWrapper.swift @@ -38,30 +38,12 @@ public class _StandardPropertyWrapper { extension _StandardPropertyWrapper: SpeziPropertyWrapper { - func inject(spezi: Spezi) { + func inject(spezi: Spezi) throws(SpeziPropertyError) { guard let standard = spezi.standard as? Constraint else { let standardType = type(of: spezi.standard) - preconditionFailure( - """ - The `Standard` defined in the `Configuration` does not conform to \(String(describing: Constraint.self)). - - Ensure that you define an appropriate standard in your configuration in your `SpeziAppDelegate` subclass ... - ``` - var configuration: Configuration { - Configuration(standard: \(String(describing: standardType))()) { - // ... - } - } - ``` - - ... and that your standard conforms to \(String(describing: Constraint.self)): - - ```swift - actor \(String(describing: standardType)): Standard, \(String(describing: Constraint.self)) { - // ... - } - ``` - """ + throw SpeziPropertyError.unsatisfiedStandardConstraint( + constraint: String(describing: Constraint.self), + standard: String(describing: standardType) ) } diff --git a/Sources/Spezi/Utilities/Application.swift b/Sources/Spezi/Utilities/Application+TypeAlias.swift similarity index 77% rename from Sources/Spezi/Utilities/Application.swift rename to Sources/Spezi/Utilities/Application+TypeAlias.swift index c2a6f0ea..158fafe6 100644 --- a/Sources/Spezi/Utilities/Application.swift +++ b/Sources/Spezi/Utilities/Application+TypeAlias.swift @@ -24,4 +24,12 @@ public typealias _Application = NSApplication // swiftlint:disable:this type_nam /// /// Type-alias for the `WKApplication`. public typealias _Application = WKApplication // swiftlint:disable:this type_name + +extension WKApplication { + /// Allow the same access pattern for WKApplication. Bridges to the `shared()` method. + @_documentation(visibility: internal) + public static var shared: WKApplication { + shared() + } +} #endif diff --git a/Sources/XCTSpezi/DependencyResolution.swift b/Sources/XCTSpezi/DependencyResolution.swift index 5e45f094..7f05206e 100644 --- a/Sources/XCTSpezi/DependencyResolution.swift +++ b/Sources/XCTSpezi/DependencyResolution.swift @@ -6,7 +6,7 @@ // SPDX-License-Identifier: MIT // -@_spi(Spezi) import Spezi +@_spi(Spezi) @_spi(Internal) import Spezi import SwiftUI @@ -34,7 +34,8 @@ public func withDependencyResolution( #if os(iOS) || os(visionOS) || os(tvOS) if case let .launchWithOptions(options) = simulateLifecycle { // maintain backwards compatibility - spezi.lifecycleHandler.willFinishLaunchingWithOptions(UIApplication.shared, launchOptions: options) + (spezi as DeprecatedLaunchOptionsCall) + .callWillFinishLaunching(UIApplication.shared, launchOptions: options) } #endif } diff --git a/Tests/SpeziTests/CapabilityTests/ModuleCommunicationTests.swift b/Tests/SpeziTests/CapabilityTests/ModuleCommunicationTests.swift index fc0968f9..98925d3a 100644 --- a/Tests/SpeziTests/CapabilityTests/ModuleCommunicationTests.swift +++ b/Tests/SpeziTests/CapabilityTests/ModuleCommunicationTests.swift @@ -55,7 +55,7 @@ final class ModuleCommunicationTests: XCTestCase { @MainActor - override func setUp() { + override func setUp() async throws { Self.provideModule = ProvideModule1() Self.collectModule = CollectModule() } @@ -69,19 +69,4 @@ final class ModuleCommunicationTests: XCTestCase { XCTAssertTrue(Self.collectModule.nothingProvided.isEmpty) XCTAssertEqual(Self.collectModule.strings, ["Hello World"]) } - - @MainActor - func testIllegalAccess() throws { - let delegate = TestApplicationDelegate() - - try XCTRuntimePrecondition { - _ = Self.collectModule.strings - } - - _ = delegate.spezi // ensure init - - try XCTRuntimePrecondition { - Self.provideModule.numMaybe2 = 12 - } - } } diff --git a/Tests/SpeziTests/CapabilityTests/NotificationsTests.swift b/Tests/SpeziTests/CapabilityTests/NotificationsTests.swift index ff8846b7..11e1de52 100644 --- a/Tests/SpeziTests/CapabilityTests/NotificationsTests.swift +++ b/Tests/SpeziTests/CapabilityTests/NotificationsTests.swift @@ -12,6 +12,7 @@ import UserNotifications import XCTest +@available(*, deprecated, message: "Forward decpreation warnings.") private final class TestNotificationHandler: Module, NotificationHandler, NotificationTokenHandler { @Application(\.registerRemoteNotifications) var registerRemoteNotifications @@ -75,6 +76,7 @@ private final class TestNotificationHandler: Module, NotificationHandler, Notifi private final class EmptyNotificationHandler: Module, NotificationHandler {} +@available(*, deprecated, message: "Forward depcreation warnings") private class TestNotificationApplicationDelegate: SpeziAppDelegate { private let injectedModule: TestNotificationHandler @@ -91,6 +93,7 @@ private class TestNotificationApplicationDelegate: SpeziAppDelegate { } +@available(*, deprecated, message: "Forward depcreation warnings") final class NotificationsTests: XCTestCase { @MainActor func testRegisterNotificationsSuccessful() async throws { diff --git a/Tests/SpeziTests/DependenciesTests/DependencyBuilderTests.swift b/Tests/SpeziTests/DependenciesTests/DependencyBuilderTests.swift index 734a5b00..9a5fa9de 100644 --- a/Tests/SpeziTests/DependenciesTests/DependencyBuilderTests.swift +++ b/Tests/SpeziTests/DependenciesTests/DependencyBuilderTests.swift @@ -61,7 +61,7 @@ final class DependencyBuilderTests: XCTestCase { let module = ExampleDependencyModule { ExampleDependentModule() } - let initializedModules = DependencyManager.resolve([module]) + let initializedModules = DependencyManager.resolveWithoutErrors([module]) XCTAssertEqual(initializedModules.count, 2) _ = try XCTUnwrap(initializedModules[0] as? ExampleDependentModule) _ = try XCTUnwrap(initializedModules[1] as? ExampleDependencyModule) diff --git a/Tests/SpeziTests/DependenciesTests/DependencyManager+OneShot.swift b/Tests/SpeziTests/DependenciesTests/DependencyManager+OneShot.swift index 127f3fd6..486757b5 100644 --- a/Tests/SpeziTests/DependenciesTests/DependencyManager+OneShot.swift +++ b/Tests/SpeziTests/DependenciesTests/DependencyManager+OneShot.swift @@ -7,12 +7,19 @@ // @testable import Spezi +import XCTest extension DependencyManager { - static func resolve(_ modules: [any Module]) -> [any Module] { + static func resolve(_ modules: [any Module]) throws -> [any Module] { let dependencyManager = DependencyManager(modules) - dependencyManager.resolve() + try dependencyManager.resolve() + return dependencyManager.initializedModules + } + + static func resolveWithoutErrors(_ modules: [any Module], file: StaticString = #filePath, line: UInt = #line) -> [any Module] { + let dependencyManager = DependencyManager(modules) + XCTAssertNoThrow(try dependencyManager.resolve(), file: file, line: line) return dependencyManager.initializedModules } } diff --git a/Tests/SpeziTests/DependenciesTests/DependencyTests.swift b/Tests/SpeziTests/DependenciesTests/DependencyTests.swift index 66974574..c2693b5d 100644 --- a/Tests/SpeziTests/DependenciesTests/DependencyTests.swift +++ b/Tests/SpeziTests/DependenciesTests/DependencyTests.swift @@ -245,9 +245,16 @@ final class DependencyTests: XCTestCase { // swiftlint:disable:this type_body_le let module3 = TestModule3() let spezi = Spezi(standard: DefaultStandard(), modules: [TestModule1(), module3]) - try XCTRuntimePrecondition { - // cannot unload module that other modules still depend on - spezi.unloadModule(module3) + // cannot unload module that other modules still depend on + XCTAssertThrowsError(try spezi._unloadModule(module3)) { error in + guard let moduleError = error as? SpeziModuleError, + case let .moduleStillRequired(module, dependents) = moduleError else { + XCTFail("Received unexpected error: \(error)") + return + } + + XCTAssertEqual(module, "TestModule3") + XCTAssertEqual(Set(dependents), ["TestModule2", "TestModule1"]) } } @@ -367,7 +374,7 @@ final class DependencyTests: XCTestCase { // swiftlint:disable:this type_body_le TestModule1(), TestModule7() ] - let initializedModules = DependencyManager.resolve(modules) + let initializedModules = DependencyManager.resolveWithoutErrors(modules) XCTAssertEqual(initializedModules.count, 7) @@ -395,7 +402,7 @@ final class DependencyTests: XCTestCase { // swiftlint:disable:this type_body_le TestModule2(), TestModule5() ] - let initializedModules = DependencyManager.resolve(modules) + let initializedModules = DependencyManager.resolveWithoutErrors(modules) XCTAssertEqual(initializedModules.count, 4) @@ -417,7 +424,7 @@ final class DependencyTests: XCTestCase { // swiftlint:disable:this type_body_le TestModule4(), TestModule4() ] - let initializedModules = DependencyManager.resolve(modules) + let initializedModules = DependencyManager.resolveWithoutErrors(modules) XCTAssertEqual(initializedModules.count, 3) @@ -437,7 +444,7 @@ final class DependencyTests: XCTestCase { // swiftlint:disable:this type_body_le TestModule2(), TestModule2() ] - let initializedModules = DependencyManager.resolve(modules) + let initializedModules = DependencyManager.resolveWithoutErrors(modules) XCTAssertEqual(initializedModules.count, 5) @@ -466,7 +473,7 @@ final class DependencyTests: XCTestCase { // swiftlint:disable:this type_body_le @MainActor func testModuleNoDependency() throws { let modules: [any Module] = [TestModule5()] - let initializedModules = DependencyManager.resolve(modules) + let initializedModules = DependencyManager.resolveWithoutErrors(modules) XCTAssertEqual(initializedModules.count, 1) @@ -480,7 +487,7 @@ final class DependencyTests: XCTestCase { // swiftlint:disable:this type_body_le TestModule5(), TestModule5() ] - let initializedModules = DependencyManager.resolve(modules) + let initializedModules = DependencyManager.resolveWithoutErrors(modules) XCTAssertEqual(initializedModules.count, 3) @@ -495,7 +502,7 @@ final class DependencyTests: XCTestCase { // swiftlint:disable:this type_body_le OptionalModuleDependency() ] - let modules = DependencyManager.resolve(nonPresent) + let modules = DependencyManager.resolveWithoutErrors(nonPresent) XCTAssertEqual(modules.count, 1) @@ -510,7 +517,7 @@ final class DependencyTests: XCTestCase { // swiftlint:disable:this type_body_le TestModule3() ] - let modules = DependencyManager.resolve(nonPresent) + let modules = DependencyManager.resolveWithoutErrors(nonPresent) XCTAssertEqual(modules.count, 2) @@ -521,14 +528,14 @@ final class DependencyTests: XCTestCase { // swiftlint:disable:this type_body_le @MainActor func testOptionalDependencyWithDynamicRuntimeDefaultValue() throws { - let nonPresent = DependencyManager.resolve([ + let nonPresent = DependencyManager.resolveWithoutErrors([ OptionalDependencyWithRuntimeDefault(defaultValue: nil) // stays optional ]) let dut1 = try XCTUnwrap(nonPresent[0] as? OptionalDependencyWithRuntimeDefault) XCTAssertNil(dut1.testModule3) - let configured = DependencyManager.resolve([ + let configured = DependencyManager.resolveWithoutErrors([ TestModule3(state: 1), OptionalDependencyWithRuntimeDefault(defaultValue: nil) ]) @@ -537,7 +544,7 @@ final class DependencyTests: XCTestCase { // swiftlint:disable:this type_body_le let dut2Module = try XCTUnwrap(dut2.testModule3) XCTAssertEqual(dut2Module.state, 1) - let defaulted = DependencyManager.resolve([ + let defaulted = DependencyManager.resolveWithoutErrors([ OptionalDependencyWithRuntimeDefault(defaultValue: 2) ]) @@ -545,7 +552,7 @@ final class DependencyTests: XCTestCase { // swiftlint:disable:this type_body_le let dut3Module = try XCTUnwrap(dut3.testModule3) XCTAssertEqual(dut3Module.state, 2) - let configuredAndDefaulted = DependencyManager.resolve([ + let configuredAndDefaulted = DependencyManager.resolveWithoutErrors([ TestModule3(state: 4), OptionalDependencyWithRuntimeDefault(defaultValue: 3) ]) @@ -645,8 +652,16 @@ final class DependencyTests: XCTestCase { // swiftlint:disable:this type_body_le let module2 = TestModuleCircle2() let module1 = TestModuleCircle1(module: module2) - try XCTRuntimePrecondition { - _ = DependencyManager.resolve([module1]) + XCTAssertThrowsError(try DependencyManager.resolve([module1])) { error in + guard let dependencyError = error as? DependencyManagerError, + case let .searchStackCycle(module, requestedModule, dependencyChain) = dependencyError else { + XCTFail("Received unexpected error: \(error)") + return + } + + XCTAssertEqual(module, "TestModuleCircle2") + XCTAssertEqual(requestedModule, "TestModuleCircle1") + XCTAssertEqual(dependencyChain, ["TestModuleCircle1", "TestModuleCircle2"]) } } @@ -656,8 +671,16 @@ final class DependencyTests: XCTestCase { // swiftlint:disable:this type_body_le @Dependency(TestModuleX.self) var module } - try XCTRuntimePrecondition { - _ = DependencyManager.resolve([Module1()]) + XCTAssertThrowsError(try DependencyManager.resolve([Module1()])) { error in + guard let dependencyError = error as? DependencyManagerError, + case let .missingRequiredModule(module, requiredModule) = dependencyError else { + XCTFail("Received unexpected error: \(error)") + return + } + + print(error) + XCTAssertEqual(module, "Module1") + XCTAssertEqual(requiredModule, "TestModuleX") } } diff --git a/Tests/SpeziTests/DependenciesTests/DynamicDependenciesTests.swift b/Tests/SpeziTests/DependenciesTests/DynamicDependenciesTests.swift index 3ee6bee4..e5d5f45a 100644 --- a/Tests/SpeziTests/DependenciesTests/DynamicDependenciesTests.swift +++ b/Tests/SpeziTests/DependenciesTests/DynamicDependenciesTests.swift @@ -96,7 +96,7 @@ final class DynamicDependenciesTests: XCTestCase { TestModule1(dynamicDependenciesTestCase) ] - let initializedModules = DependencyManager.resolve(modules) + let initializedModules = DependencyManager.resolveWithoutErrors(modules) XCTAssertEqual(initializedModules.count, dynamicDependenciesTestCase.expectedNumberOfModules) try initializedModules.moduleOfType(TestModule1.self).evaluateExpectations() diff --git a/Tests/SpeziTests/ModuleTests/ModuleBuilderTests.swift b/Tests/SpeziTests/ModuleTests/ModuleBuilderTests.swift index 890065d5..c76a6d67 100644 --- a/Tests/SpeziTests/ModuleTests/ModuleBuilderTests.swift +++ b/Tests/SpeziTests/ModuleTests/ModuleBuilderTests.swift @@ -90,9 +90,7 @@ final class ModuleBuilderTests: XCTestCase { expectations: expectations ) - let dependencyManager = DependencyManager(modules.elements) - dependencyManager.resolve() - for module in dependencyManager.initializedModules { + for module in DependencyManager.resolveWithoutErrors(modules.elements) { module.configure() } @@ -111,10 +109,8 @@ final class ModuleBuilderTests: XCTestCase { condition: false, expectations: expectations ) - - let dependencyManager = DependencyManager(modules.elements) - dependencyManager.resolve() - for module in dependencyManager.initializedModules { + + for module in DependencyManager.resolveWithoutErrors(modules.elements) { module.configure() } diff --git a/Tests/SpeziTests/ModuleTests/ModuleTests.swift b/Tests/SpeziTests/ModuleTests/ModuleTests.swift index 4772c039..16802d50 100644 --- a/Tests/SpeziTests/ModuleTests/ModuleTests.swift +++ b/Tests/SpeziTests/ModuleTests/ModuleTests.swift @@ -72,16 +72,6 @@ final class ModuleTests: XCTestCase { unsetenv(ProcessInfo.xcodeRunningForPreviewKey) } - @MainActor - func testPreviewModifierOnlyWithinPreview() throws { - try XCTRuntimePrecondition { - _ = Text("Spezi") - .previewWith { - TestModule() - } - } - } - @MainActor func testModuleCreation() { let expectation = XCTestExpectation(description: "DependingTestModule") diff --git a/Tests/SpeziTests/StandardTests/StandardUnfulfilledConstraintTests.swift b/Tests/SpeziTests/StandardTests/StandardUnfulfilledConstraintTests.swift index cd2d3613..f6ef4bbe 100644 --- a/Tests/SpeziTests/StandardTests/StandardUnfulfilledConstraintTests.swift +++ b/Tests/SpeziTests/StandardTests/StandardUnfulfilledConstraintTests.swift @@ -29,20 +29,21 @@ final class StandardUnfulfilledConstraintTests: XCTestCase { } } - class StandardUCTestApplicationDelegate: SpeziAppDelegate { - override var configuration: Configuration { - Configuration(standard: MockStandard()) { - StandardUCTestModule() - } - } - } - @MainActor func testStandardUnfulfilledConstraint() throws { - let standardCUTestApplicationDelegate = StandardUCTestApplicationDelegate() - try XCTRuntimePrecondition(timeout: 0.5) { - _ = standardCUTestApplicationDelegate.spezi + let configuration = Configuration(standard: MockStandard()) {} + let spezi = Spezi(from: configuration) + + XCTAssertThrowsError(try spezi.loadModules([StandardUCTestModule()], ownership: .spezi)) { error in + guard let moduleError = error as? SpeziModuleError, + case let .property(propertyError) = moduleError, + case let .unsatisfiedStandardConstraint(constraint, standard) = propertyError else { + XCTFail("Encountered unexpected error: \(error)") + return + } + XCTAssertEqual(constraint, "UnfulfilledExampleConstraint") + XCTAssertEqual(standard, "MockStandard") } } } diff --git a/Tests/UITests/TestApp/LifecycleHandler/LifecycleHandlerTestModule.swift b/Tests/UITests/TestApp/LifecycleHandler/LifecycleHandlerTestModule.swift index b3ad0b9e..83ad166c 100644 --- a/Tests/UITests/TestApp/LifecycleHandler/LifecycleHandlerTestModule.swift +++ b/Tests/UITests/TestApp/LifecycleHandler/LifecycleHandlerTestModule.swift @@ -43,7 +43,7 @@ struct LifecycleHandlerModifier: ViewModifier { } -final class LifecycleHandlerTestModule: Module, LifecycleHandler { +final class LifecycleHandlerTestModule: Module { private let model: LifecycleHandlerModel @Modifier var modifier: LifecycleHandlerModifier @@ -99,3 +99,7 @@ final class LifecycleHandlerTestModule: Module, LifecycleHandler { } #endif } + + +@available(*, deprecated, message: "Propagate deprecation warning.") +extension LifecycleHandlerTestModule: LifecycleHandler {} diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index bba55d60..7253e12c 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 77; objects = { /* Begin PBXBuildFile section */ @@ -245,7 +245,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1410; - LastUpgradeCheck = 1540; + LastUpgradeCheck = 1600; TargetAttributes = { 2F6D139128F5F384007C25D6 = { CreatedOnToolsVersion = 14.1; @@ -257,7 +257,6 @@ }; }; buildConfigurationList = 2F6D138D28F5F384007C25D6 /* Build configuration list for PBXProject "UITests" */; - compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -268,6 +267,7 @@ packageReferences = ( 2F746D9D29962B2A00BF54FE /* XCRemoteSwiftPackageReference "XCTestExtensions" */, ); + preferredProjectObjectVersion = 77; productRefGroup = 2F6D139328F5F384007C25D6 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -400,6 +400,7 @@ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; TVOS_DEPLOYMENT_TARGET = 17.0; XROS_DEPLOYMENT_TARGET = 1.0; }; @@ -460,6 +461,7 @@ SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; TVOS_DEPLOYMENT_TARGET = 17.0; VALIDATE_PRODUCT = YES; XROS_DEPLOYMENT_TARGET = 1.0; @@ -543,7 +545,6 @@ 2F6D13BD28F5F386007C25D6 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 637867499T; @@ -568,7 +569,6 @@ 2F6D13BE28F5F386007C25D6 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 637867499T; diff --git a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme index eb317413..de592e4c 100644 --- a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme +++ b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme @@ -1,6 +1,6 @@