From 4fbd38505fe1b0779856ab3b9adb00538581a0c9 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 29 Aug 2024 15:06:50 +0200 Subject: [PATCH 1/7] Ensure access to PDFDocument is always concurrency safe --- .../SpeziOnboarding/ConsentConstraint.swift | 2 +- .../ConsentView/ConsentDocumentExport.swift | 40 ++++++++++++------- .../OnboardingConstraint.swift | 2 +- .../OnboardingDataSource.swift | 24 +++++++++-- Tests/UITests/TestApp/ExampleStandard.swift | 40 ++++++++++--------- 5 files changed, 70 insertions(+), 38 deletions(-) diff --git a/Sources/SpeziOnboarding/ConsentConstraint.swift b/Sources/SpeziOnboarding/ConsentConstraint.swift index 5fc7b9f..b2f9b3b 100644 --- a/Sources/SpeziOnboarding/ConsentConstraint.swift +++ b/Sources/SpeziOnboarding/ConsentConstraint.swift @@ -17,5 +17,5 @@ public protocol ConsentConstraint: Standard { /// /// - Parameters: /// - consent: The exported consent form represented as `ConsentDocumentExport` that should be added. - func store(consent: ConsentDocumentExport) async throws + func store(consent: consuming sending ConsentDocumentExport) async throws } diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift index 18a9545..618f7b1 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift @@ -8,40 +8,50 @@ import PDFKit + /// A type representing an exported `ConsentDocument`. It holds the exported `PDFDocument` and the corresponding document identifier String. -public actor ConsentDocumentExport { +public struct ConsentDocumentExport: ~Copyable { /// Provides default values for fields related to the `ConsentDocumentExport`. public enum Defaults { /// Default value for a document identifier. + /// /// This identifier will be used as default value if no identifier is provided. public static let documentIdentifier = "ConsentDocument" } - private var cachedPDF: PDFDocument - + private let cachedPDF: PDFDocument + /// An unique identifier for the exported `ConsentDocument`. + /// /// Corresponds to the identifier which was passed when creating the `ConsentDocument` using an `OnboardingConsentView`. public let documentIdentifier: String - /// The `PDFDocument` exported from a `ConsentDocument`. - /// This property is asynchronous and accesing it potentially triggers the export of the PDF from the underlying `ConsentDocument`, - /// if the `ConsentDocument` has not been previously exported or the `PDFDocument` was not cached. - /// For now, we always require a PDF to be cached to create a ConsentDocumentExport. In the future, we might change this to lazy-PDF loading. - public var pdf: PDFDocument { - get async { - cachedPDF - } - } - + /// Creates a `ConsentDocumentExport`, which holds an exported PDF and the corresponding document identifier string. /// - Parameters: - /// - documentIdentfier: A unique String identifying the exported `ConsentDocument`. + /// - documentIdentifier: A unique String identifying the exported `ConsentDocument`. /// - cachedPDF: A `PDFDocument` exported from a `ConsentDocument`. init( documentIdentifier: String, - cachedPDF: PDFDocument + cachedPDF: sending PDFDocument ) { self.documentIdentifier = documentIdentifier self.cachedPDF = cachedPDF } + + /// Consume the exported `PDFDocument` from a `ConsentDocument`. + /// + /// This method consumes the `ConsentDocumentExport` by retrieving the exported `PDFDocument`. + /// + /// This property is asynchronous and accessing it potentially triggers the export of the PDF from the underlying `ConsentDocument`, + /// if the `ConsentDocument` has not been previously exported or the `PDFDocument` was not cached. + /// + /// - Note: For now, we always require a PDF to be cached to create a ConsentDocumentExport. In the future, we might change this to lazy-PDF loading. + public consuming func consumePDF() async -> sending PDFDocument { + // Something the compiler doesn't realize here is that we can send the `PDFDocument` because it is located in a non-Sendable, non-Copyable + // type and accessing it will consume the enclosing type. Therefore, the PDFDocument instance can only be accessed once + // and that is fully checked at compile time by the compiler :rocket: + nonisolated(unsafe) let cachedPDF = cachedPDF + return cachedPDF + } } diff --git a/Sources/SpeziOnboarding/OnboardingConstraint.swift b/Sources/SpeziOnboarding/OnboardingConstraint.swift index 3d14dec..346634c 100644 --- a/Sources/SpeziOnboarding/OnboardingConstraint.swift +++ b/Sources/SpeziOnboarding/OnboardingConstraint.swift @@ -33,5 +33,5 @@ public protocol OnboardingConstraint: Standard { Please use `ConsentConstraint.store(consent: PDFDocument, identifier: String)` instead. """ ) - func store(consent: PDFDocument) async + func store(consent: sending PDFDocument) async } diff --git a/Sources/SpeziOnboarding/OnboardingDataSource.swift b/Sources/SpeziOnboarding/OnboardingDataSource.swift index 55fa7ee..eb1cc95 100644 --- a/Sources/SpeziOnboarding/OnboardingDataSource.swift +++ b/Sources/SpeziOnboarding/OnboardingDataSource.swift @@ -11,6 +11,11 @@ import Spezi import SwiftUI +private protocol DeprecationSuppression { + func storeInLegacyConstraint(for standard: any Standard, _ consent: sending PDFDocument) async +} + + /// Configuration for the Spezi Onboarding module. /// /// Make sure that your standard in your Spezi Application conforms to the ``OnboardingConstraint`` @@ -33,13 +38,14 @@ import SwiftUI /// } /// } /// ``` -public class OnboardingDataSource: Module, EnvironmentAccessible { +public final class OnboardingDataSource: Module, EnvironmentAccessible, @unchecked Sendable { @StandardActor var standard: any Standard public init() { } + @available(*, deprecated, message: "Propagate deprecation warning") public func configure() { guard standard is any OnboardingConstraint || standard is any ConsentConstraint else { fatalError("A \(type(of: standard).self) must conform to `ConsentConstraint` to process signed consent documents.") @@ -49,11 +55,23 @@ public class OnboardingDataSource: Module, EnvironmentAccessible { /// Adds a new exported consent form represented as `PDFDocument` to the ``OnboardingDataSource``. /// /// - Parameter consent: The exported consent form represented as `ConsentDocumentExport` that should be added. - public func store(_ consent: PDFDocument, identifier: String = ConsentDocumentExport.Defaults.documentIdentifier) async throws { + public func store(_ consent: sending PDFDocument, identifier: String = ConsentDocumentExport.Defaults.documentIdentifier) async throws { if let consentConstraint = standard as? any ConsentConstraint { let consentDocumentExport = ConsentDocumentExport(documentIdentifier: identifier, cachedPDF: consent) try await consentConstraint.store(consent: consentDocumentExport) - } else if let onboardingConstraint = standard as? any OnboardingConstraint { + } else { + // By down-casting to the protocol we avoid "seeing" the deprecation warning, allowing us to hide it from the compiler. + // We need to call the deprecated symbols for backwards-compatibility. + await (self as DeprecationSuppression).storeInLegacyConstraint(for: standard, consent) + } + } +} + + +extension OnboardingDataSource: DeprecationSuppression { + @available(*, deprecated, message: "Suppress deprecation warning.") + func storeInLegacyConstraint(for standard: any Standard, _ consent: sending PDFDocument) async { + if let onboardingConstraint = standard as? any OnboardingConstraint { await onboardingConstraint.store(consent: consent) } else { fatalError("A \(type(of: standard).self) must conform to `ConsentConstraint` to process signed consent documents.") diff --git a/Tests/UITests/TestApp/ExampleStandard.swift b/Tests/UITests/TestApp/ExampleStandard.swift index c3fb232..abe500b 100644 --- a/Tests/UITests/TestApp/ExampleStandard.swift +++ b/Tests/UITests/TestApp/ExampleStandard.swift @@ -21,25 +21,28 @@ actor ExampleStandard: Standard, EnvironmentAccessible { extension ExampleStandard: ConsentConstraint { // Example of an async function using MainActor and Task - func store(consent: ConsentDocumentExport) async throws { + func store(consent: consuming sending ConsentDocumentExport) async throws { // Extract data outside of the MainActor.run block - let documentIdentifier = await consent.documentIdentifier - let pdf = await consent.pdf - + let documentIdentifier = consent.documentIdentifier + let pdf = await consent.consumePDF() + // Perform operations on the main actor - try await MainActor.run { - if documentIdentifier == DocumentIdentifiers.first { - self.firstConsentData = pdf - } else if documentIdentifier == DocumentIdentifiers.second { - self.secondConsentData = pdf - } else { - throw ConsentStoreError.invalidIdentifier("Invalid Identifier \(documentIdentifier)") - } - } - + try await self.store(document: pdf, for: documentIdentifier) + try? await Task.sleep(for: .seconds(0.5)) } - + + @MainActor + func store(document pdf: sending PDFDocument, for documentIdentifier: String) throws { + if documentIdentifier == DocumentIdentifiers.first { + self.firstConsentData = pdf + } else if documentIdentifier == DocumentIdentifiers.second { + self.secondConsentData = pdf + } else { + throw ConsentStoreError.invalidIdentifier("Invalid Identifier \(documentIdentifier)") + } + } + func resetDocument(identifier: String) async throws { await MainActor.run { if identifier == DocumentIdentifiers.first { @@ -49,12 +52,13 @@ extension ExampleStandard: ConsentConstraint { } } } - + + @MainActor func loadConsentDocument(identifier: String) async throws -> PDFDocument? { if identifier == DocumentIdentifiers.first { - return await self.firstConsentData + return self.firstConsentData } else if identifier == DocumentIdentifiers.second { - return await self.secondConsentData + return self.secondConsentData } // In case an invalid identifier is provided, return nil. From 4b165e3bd5ccc0cc00cab374d7bf36ba08564b5d Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Thu, 29 Aug 2024 15:32:37 +0200 Subject: [PATCH 2/7] Minor annotations --- .../SpeziOnboarding/ConsentView/ConsentDocumentExport.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift index 618f7b1..6c44664 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift @@ -49,8 +49,9 @@ public struct ConsentDocumentExport: ~Copyable { /// - Note: For now, we always require a PDF to be cached to create a ConsentDocumentExport. In the future, we might change this to lazy-PDF loading. public consuming func consumePDF() async -> sending PDFDocument { // Something the compiler doesn't realize here is that we can send the `PDFDocument` because it is located in a non-Sendable, non-Copyable - // type and accessing it will consume the enclosing type. Therefore, the PDFDocument instance can only be accessed once + // type and accessing it will consume the enclosing type. Therefore, the PDFDocument instance can only be accessed once (even in async method) // and that is fully checked at compile time by the compiler :rocket: + // See similar discussion: https://forums.swift.org/t/swift-6-consume-optional-noncopyable-property-and-transfer-sending-it-out/72414/3 nonisolated(unsafe) let cachedPDF = cachedPDF return cachedPDF } From cb23e1a7533d009332897318f708040e8c30c57d Mon Sep 17 00:00:00 2001 From: Paul Schmiedmayer Date: Mon, 20 Jan 2025 09:50:55 -0800 Subject: [PATCH 3/7] Update Swift 6 Compatability --- Package.swift | 19 ++++++------------ .../OnboardingActionsView.swift | 20 +++++++++---------- .../OnboardingDataSource.swift | 2 +- .../NavigationPath+Codable.swift | 2 +- Sources/SpeziOnboarding/OnboardingView.swift | 4 ++-- .../SequentialOnboardingView.swift | 8 ++++---- Sources/SpeziOnboarding/SignatureView.swift | 7 +++++-- .../UITests/UITests.xcodeproj/project.pbxproj | 4 ++-- 8 files changed, 31 insertions(+), 35 deletions(-) diff --git a/Package.swift b/Package.swift index 1887d04..58d818f 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 @@ -12,13 +12,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: "SpeziOnboarding", defaultLocalization: "en", @@ -31,9 +24,9 @@ let package = Package( .library(name: "SpeziOnboarding", targets: ["SpeziOnboarding"]) ], dependencies: [ - .package(url: "https://github.com/StanfordSpezi/Spezi", from: "1.2.1"), - .package(url: "https://github.com/StanfordSpezi/SpeziViews", from: "1.3.1"), - .package(url: "https://github.com/apple/swift-collections.git", from: "1.1.0") + .package(url: "https://github.com/StanfordSpezi/Spezi.git", from: "1.8.0"), + .package(url: "https://github.com/StanfordSpezi/SpeziViews.git", from: "1.8.0"), + .package(url: "https://github.com/apple/swift-collections.git", from: "1.1.4") ] + swiftLintPackage(), targets: [ .target( @@ -45,7 +38,7 @@ let package = Package( .product(name: "OrderedCollections", package: "swift-collections") ], swiftSettings: [ - swiftConcurrency + .enableUpcomingFeature("ExistentialAny") ], plugins: [] + swiftLintPlugin() ), @@ -55,7 +48,7 @@ let package = Package( .target(name: "SpeziOnboarding") ], swiftSettings: [ - swiftConcurrency + .enableUpcomingFeature("ExistentialAny") ], plugins: [] + swiftLintPlugin() ) diff --git a/Sources/SpeziOnboarding/OnboardingActionsView.swift b/Sources/SpeziOnboarding/OnboardingActionsView.swift index 343e4d4..d051921 100644 --- a/Sources/SpeziOnboarding/OnboardingActionsView.swift +++ b/Sources/SpeziOnboarding/OnboardingActionsView.swift @@ -28,9 +28,9 @@ import SwiftUI /// ``` public struct OnboardingActionsView: View { private let primaryText: Text - private let primaryAction: () async throws -> Void + private let primaryAction: @MainActor () async throws -> Void private let secondaryText: Text? - private let secondaryAction: (() async throws -> Void)? + private let secondaryAction: (@MainActor () async throws -> Void)? @State private var primaryActionState: ViewState = .idle @State private var secondaryActionState: ViewState = .idle @@ -58,9 +58,9 @@ public struct OnboardingActionsView: View { init( primaryText: Text, - primaryAction: @escaping () async throws -> Void, + primaryAction: @MainActor @escaping () async throws -> Void, secondaryText: Text? = nil, - secondaryAction: (() async throws -> Void)? = nil + secondaryAction: (@MainActor () async throws -> Void)? = nil ) { self.primaryText = primaryText self.primaryAction = primaryAction @@ -75,7 +75,7 @@ public struct OnboardingActionsView: View { @_disfavoredOverload public init( verbatim text: Text, - action: @escaping () async throws -> Void + action: @MainActor @escaping () async throws -> Void ) { self.init(primaryText: SwiftUI.Text(verbatim: String(text)), primaryAction: action) } @@ -86,7 +86,7 @@ public struct OnboardingActionsView: View { /// - action: The action that should be performed when pressing the primary button public init( _ text: LocalizedStringResource, - action: @escaping () async throws -> Void + action: @MainActor @escaping () async throws -> Void ) { self.init(primaryText: Text(text), primaryAction: action) } @@ -99,9 +99,9 @@ public struct OnboardingActionsView: View { /// - secondaryAction: The action that should be performed when pressing the secondary button public init( primaryText: LocalizedStringResource, - primaryAction: @escaping () async throws -> Void, + primaryAction: @MainActor @escaping () async throws -> Void, secondaryText: LocalizedStringResource, - secondaryAction: @escaping () async throws -> Void + secondaryAction: @MainActor @escaping () async throws -> Void ) { self.init(primaryText: Text(primaryText), primaryAction: primaryAction, secondaryText: Text(secondaryText), secondaryAction: secondaryAction) } @@ -115,9 +115,9 @@ public struct OnboardingActionsView: View { @_disfavoredOverload public init( primaryText: PrimaryText, - primaryAction: @escaping () async throws -> Void, + primaryAction: @MainActor @escaping () async throws -> Void, secondaryText: SecondaryText, - secondaryAction: @escaping () async throws -> Void + secondaryAction: @MainActor @escaping () async throws -> Void ) { self.init( primaryText: Text(verbatim: String(primaryText)), diff --git a/Sources/SpeziOnboarding/OnboardingDataSource.swift b/Sources/SpeziOnboarding/OnboardingDataSource.swift index eb1cc95..12d3211 100644 --- a/Sources/SpeziOnboarding/OnboardingDataSource.swift +++ b/Sources/SpeziOnboarding/OnboardingDataSource.swift @@ -62,7 +62,7 @@ public final class OnboardingDataSource: Module, EnvironmentAccessible, @uncheck } else { // By down-casting to the protocol we avoid "seeing" the deprecation warning, allowing us to hide it from the compiler. // We need to call the deprecated symbols for backwards-compatibility. - await (self as DeprecationSuppression).storeInLegacyConstraint(for: standard, consent) + await (self as any DeprecationSuppression).storeInLegacyConstraint(for: standard, consent) } } } diff --git a/Sources/SpeziOnboarding/OnboardingFlow/NavigationPath+Codable.swift b/Sources/SpeziOnboarding/OnboardingFlow/NavigationPath+Codable.swift index 0d3b080..a341f18 100644 --- a/Sources/SpeziOnboarding/OnboardingFlow/NavigationPath+Codable.swift +++ b/Sources/SpeziOnboarding/OnboardingFlow/NavigationPath+Codable.swift @@ -23,7 +23,7 @@ extension NavigationPath { /// Decodes the given `Decoder` instance into an `OnboardingStepIdentifier`. /// This involves decoding an unkeyed container, skipping the initial string, and then decoding the actual `OnboardingStepIdentifier`. - init(from decoder: Decoder) throws { + init(from decoder: any Decoder) throws { var container = try decoder.unkeyedContainer() // Type name within the navigation path that is not needed diff --git a/Sources/SpeziOnboarding/OnboardingView.swift b/Sources/SpeziOnboarding/OnboardingView.swift index fbd8c99..7e79ee0 100644 --- a/Sources/SpeziOnboarding/OnboardingView.swift +++ b/Sources/SpeziOnboarding/OnboardingView.swift @@ -125,9 +125,9 @@ public struct OnboardingView Void diff --git a/Sources/SpeziOnboarding/SequentialOnboardingView.swift b/Sources/SpeziOnboarding/SequentialOnboardingView.swift index bbed39f..d84a4e4 100644 --- a/Sources/SpeziOnboarding/SequentialOnboardingView.swift +++ b/Sources/SpeziOnboarding/SequentialOnboardingView.swift @@ -52,8 +52,8 @@ public struct SequentialOnboardingView: View { /// - Parameters: /// - title: The localized title of the area in the ``SequentialOnboardingView``. /// - description: The localized description of the area in the ``SequentialOnboardingView``. - public init( // swiftlint:disable:this function_default_parameter_at_end - title: LocalizedStringResource? = nil, + public init( + title: LocalizedStringResource? = nil, // swiftlint:disable:this function_default_parameter_at_end description: LocalizedStringResource ) { self.title = title.map { Text($0) } @@ -150,9 +150,9 @@ public struct SequentialOnboardingView: View { /// - content: The areas of the `SequentialOnboardingView` defined using ``SequentialOnboardingView/Content`` instances.. /// - actionText: The localized text that should appear on the `SequentialOnboardingView`'s primary button. /// - action: The close that is called then the primary button is pressed. - public init( // swiftlint:disable:this function_default_parameter_at_end + public init( title: LocalizedStringResource, - subtitle: LocalizedStringResource? = nil, + subtitle: LocalizedStringResource? = nil, // swiftlint:disable:this function_default_parameter_at_end content: [Content], actionText: LocalizedStringResource, action: @escaping () async throws -> Void diff --git a/Sources/SpeziOnboarding/SignatureView.swift b/Sources/SpeziOnboarding/SignatureView.swift index c98f99a..3cfbd8b 100644 --- a/Sources/SpeziOnboarding/SignatureView.swift +++ b/Sources/SpeziOnboarding/SignatureView.swift @@ -7,6 +7,7 @@ // import PencilKit +import SpeziFoundation import SpeziViews import SwiftUI @@ -49,8 +50,10 @@ public struct SignatureView: View { .accessibilityLabel(Text("SIGNATURE_FIELD", bundle: .module)) .accessibilityAddTraits(.allowsDirectInteraction) .onPreferenceChange(CanvasView.CanvasSizePreferenceKey.self) { size in - // for some reason, the preference won't update on visionOS if placed in a parent view - self.canvasSize = size + runOrScheduleOnMainActor { + // for some reason, the preference won't update on visionOS if placed in a parent view + self.canvasSize = size + } } #else signatureTextField diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index 3eacd68..1da4550 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -744,8 +744,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/StanfordBDHG/XCTestExtensions"; requirement = { - kind = upToNextMinorVersion; - minimumVersion = 0.4.9; + kind = upToNextMajorVersion; + minimumVersion = 1.1.1; }; }; /* End XCRemoteSwiftPackageReference section */ From aed76aff9a3e2302a93d2c1732fcd08576eda93d Mon Sep 17 00:00:00 2001 From: Paul Schmiedmayer Date: Mon, 20 Jan 2025 09:55:00 -0800 Subject: [PATCH 4/7] Update macOS Build --- .../ConsentView/OnboardingConsentView+ShareSheet.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/SpeziOnboarding/ConsentView/OnboardingConsentView+ShareSheet.swift b/Sources/SpeziOnboarding/ConsentView/OnboardingConsentView+ShareSheet.swift index e84f1e7..1fecf3e 100644 --- a/Sources/SpeziOnboarding/ConsentView/OnboardingConsentView+ShareSheet.swift +++ b/Sources/SpeziOnboarding/ConsentView/OnboardingConsentView+ShareSheet.swift @@ -46,6 +46,7 @@ extension OnboardingConsentView { let sharedItem: PDFDocument + @MainActor func show() { // Note: Need to write down the PDF to storage as in-memory PDFs are not recognized properly let temporaryPath = FileManager.default.temporaryDirectory.appendingPathComponent( From bc0295ddaf995302cb534f4663ebc10a74d2afc4 Mon Sep 17 00:00:00 2001 From: Philipp Zagar Date: Wed, 22 Jan 2025 00:14:16 +0100 Subject: [PATCH 5/7] final impl --- .../ConsentView/ConsentDocument.swift | 2 +- .../ConsentDocumentExport+Export.swift | 1 + .../ConsentView/ConsentDocumentExport.swift | 37 ++++++++++++------- .../ConsentView/ConsentViewState.swift | 14 ------- Tests/UITests/TestApp/ExampleStandard.swift | 6 +-- 5 files changed, 29 insertions(+), 31 deletions(-) diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift index 10fab02..2148080 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift @@ -46,7 +46,7 @@ public struct ConsentDocument: View { private let familyNameTitle: LocalizedStringResource private let familyNamePlaceholder: LocalizedStringResource - var documentExport: ConsentDocumentExport + let documentExport: ConsentDocumentExport @Environment(\.colorScheme) var colorScheme @State var name = PersonNameComponents() diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport+Export.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport+Export.swift index bd069d0..54b335a 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport+Export.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport+Export.swift @@ -11,6 +11,7 @@ import PencilKit import SwiftUI import TPPDF + /// Extension of `ConsentDocumentExport` enabling the export of the signed consent page. extension ConsentDocumentExport { /// Generates a `PDFAttributedText` containing the timestamp of the time at which the PDF was exported. diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift index 98aef5d..f5bc8ea 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift @@ -12,7 +12,7 @@ import SwiftUI /// A type representing an exported `ConsentDocument`. It holds the exported `PDFDocument` and the corresponding document identifier String. -public class ConsentDocumentExport { +public final class ConsentDocumentExport: Equatable { /// Provides default values for fields related to the `ConsentDocumentExport`. public enum Defaults { /// Default value for a document identifier. @@ -23,7 +23,7 @@ public class ConsentDocumentExport { let asyncMarkdown: () async -> Data let exportConfiguration: ConsentDocument.ExportConfiguration - var cachedPDF: PDFDocument + var cachedPDF: PDFDocument? /// An unique identifier for the exported `ConsentDocument`. /// @@ -45,13 +45,15 @@ public class ConsentDocumentExport { /// Creates a `ConsentDocumentExport`, which holds an exported PDF and the corresponding document identifier string. /// - Parameters: + /// - markdown: The markdown text for the document, which is shown to the user. + /// - exportConfiguration: The `ExportConfiguration` holding the properties of the document. /// - documentIdentifier: A unique String identifying the exported `ConsentDocument`. /// - cachedPDF: A `PDFDocument` exported from a `ConsentDocument`. init( markdown: @escaping () async -> Data, exportConfiguration: ConsentDocument.ExportConfiguration, documentIdentifier: String, - cachedPDF: sending PDFDocument = .init() + cachedPDF: sending PDFDocument? = nil ) { self.asyncMarkdown = markdown self.exportConfiguration = exportConfiguration @@ -59,20 +61,29 @@ public class ConsentDocumentExport { self.cachedPDF = cachedPDF } + + public static func == (lhs: ConsentDocumentExport, rhs: ConsentDocumentExport) -> Bool { + lhs.documentIdentifier == rhs.documentIdentifier && + lhs.name == rhs.name && + lhs.signature == rhs.signature && + lhs.cachedPDF == rhs.cachedPDF + } + + /// Consume the exported `PDFDocument` from a `ConsentDocument`. /// - /// This method consumes the `ConsentDocumentExport` by retrieving the exported `PDFDocument`. - /// - /// This property is asynchronous and accessing it potentially triggers the export of the PDF from the underlying `ConsentDocument`, - /// if the `ConsentDocument` has not been previously exported or the `PDFDocument` was not cached. + /// This method consumes the `ConsentDocumentExport/cachedPDF` by retrieving the exported `PDFDocument`. /// - /// - Note: For now, we always require a PDF to be cached to create a ConsentDocumentExport. In the future, we might change this to lazy-PDF loading. - public consuming func consumePDF() async -> sending PDFDocument { - // Something the compiler doesn't realize here is that we can send the `PDFDocument` because it is located in a non-Sendable, non-Copyable - // type and accessing it will consume the enclosing type. Therefore, the PDFDocument instance can only be accessed once (even in async method) - // and that is fully checked at compile time by the compiler :rocket: + /// - Note: For now, we always require a PDF to be cached to create a `ConsentDocumentExport`. In the future, we might change this to lazy-PDF loading. + public consuming func consumePDF() -> sending PDFDocument { + // Accessing `cachedPDF` via `take()` ensures single consumption of the `PDFDocument` by transferring ownership + // from the enclosing class and leaving `nil` behind after the access. Though `ConsentDocumentExport` is a reference + // type, this manual ownership model guarantees the PDF is only used once, enabling safe cross-concurrency transfer. + // The explicit `sending` return type reinforces transfer semantics, while `take()` enforces single-access at runtime. + // This pattern provides compiler-verifiable safety for the `PDFDocument` transfer despite the class's reference semantics. + // // See similar discussion: https://forums.swift.org/t/swift-6-consume-optional-noncopyable-property-and-transfer-sending-it-out/72414/3 - nonisolated(unsafe) let cachedPDF = cachedPDF + nonisolated(unsafe) let cachedPDF = cachedPDF.take() ?? .init() return cachedPDF } } diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentViewState.swift b/Sources/SpeziOnboarding/ConsentView/ConsentViewState.swift index fa7219b..a31108a 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentViewState.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentViewState.swift @@ -37,18 +37,4 @@ public enum ConsentViewState: Equatable { case exported(document: PDFDocument, export: ConsentDocumentExport) /// The `storing` state indicates that the ``ConsentDocument`` is currently being stored to the Standard. case storing - - - // Manual implementation necessary as `ConsentDocumentExport` not equatable - public static func == (lhs: ConsentViewState, rhs: ConsentViewState) -> Bool { - switch (lhs, rhs) { - case let (.base(lhsViewState), .base(rhsViewState)): lhsViewState == rhsViewState - case (.namesEntered, .namesEntered): true - case (.signing, .signing): true - case (.signed, .signed): true - case (.export, .export): true - case (.exported(document: _, export: _), .exported(document: _, export: _)): true - default: false - } - } } diff --git a/Tests/UITests/TestApp/ExampleStandard.swift b/Tests/UITests/TestApp/ExampleStandard.swift index 559a915..b41c4d2 100644 --- a/Tests/UITests/TestApp/ExampleStandard.swift +++ b/Tests/UITests/TestApp/ExampleStandard.swift @@ -12,7 +12,7 @@ import SpeziOnboarding import SwiftUI -/// An example Standard used for the configuration. +/// An example `Standard` used for the configuration. actor ExampleStandard: Standard, EnvironmentAccessible { @MainActor var firstConsentData: PDFDocument = .init() @MainActor var secondConsentData: PDFDocument = .init() @@ -23,12 +23,12 @@ extension ExampleStandard: ConsentConstraint { // Example of an async function using MainActor and Task func store(consent: consuming sending ConsentDocumentExport) async throws { let documentIdentifier = consent.documentIdentifier - let pdf = await consent.consumePDF() + let pdf = consent.consumePDF() // Perform operations on the main actor try await self.store(document: pdf, for: documentIdentifier) - try? await Task.sleep(for: .seconds(0.5)) + try? await Task.sleep(for: .seconds(0.5)) // Simulates storage delay } @MainActor From ba8b40afc02fa98a515b5e2fa220a7f2f0adb1af Mon Sep 17 00:00:00 2001 From: Philipp Zagar Date: Wed, 22 Jan 2025 00:23:55 +0100 Subject: [PATCH 6/7] fix macos --- .../ConsentDocument+ExportConfiguration.swift | 54 +++++------ .../ExportConfiguration+Defaults.swift | 97 ++++++++++--------- 2 files changed, 76 insertions(+), 75 deletions(-) diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+ExportConfiguration.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+ExportConfiguration.swift index 8237d3e..a5794dd 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+ExportConfiguration.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+ExportConfiguration.swift @@ -69,23 +69,23 @@ extension ConsentDocument { /// - headerTitleFont: The font used for the header title. /// - headerExportTimeStampFont: The font used for the header timestamp. public init( - signatureNameFont: UIFont, - signaturePrefixFont: UIFont, - documentContentFont: UIFont, - headerTitleFont: UIFont, - headerExportTimeStampFont: UIFont - ) { - self.signatureNameFont = signatureNameFont - self.signaturePrefixFont = signaturePrefixFont - self.documentContentFont = documentContentFont - self.headerTitleFont = headerTitleFont - self.headerExportTimeStampFont = headerExportTimeStampFont - } + signatureNameFont: UIFont, + signaturePrefixFont: UIFont, + documentContentFont: UIFont, + headerTitleFont: UIFont, + headerExportTimeStampFont: UIFont + ) { + self.signatureNameFont = signatureNameFont + self.signaturePrefixFont = signaturePrefixFont + self.documentContentFont = documentContentFont + self.headerTitleFont = headerTitleFont + self.headerExportTimeStampFont = headerExportTimeStampFont + } } #else /// The ``FontSettings`` store configuration of the fonts used to render the exported /// consent document, i.e., fonts for the content, title and signature. - public struct FontSettings { + public struct FontSettings: @unchecked Sendable { /// The font of the name rendered below the signature line. public let signatureNameFont: NSFont /// The font of the prefix of the signature ("X" in most cases). @@ -107,18 +107,18 @@ extension ConsentDocument { /// - headerTitleFont: The font used for the header title. /// - headerExportTimeStampFont: The font used for the header timestamp. public init( - signatureNameFont: NSFont, - signaturePrefixFont: NSFont, - documentContentFont: NSFont, - headerTitleFont: NSFont, - headerExportTimeStampFont: NSFont - ) { - self.signatureNameFont = signatureNameFont - self.signaturePrefixFont = signaturePrefixFont - self.documentContentFont = documentContentFont - self.headerTitleFont = headerTitleFont - self.headerExportTimeStampFont = headerExportTimeStampFont - } + signatureNameFont: NSFont, + signaturePrefixFont: NSFont, + documentContentFont: NSFont, + headerTitleFont: NSFont, + headerExportTimeStampFont: NSFont + ) { + self.signatureNameFont = signatureNameFont + self.signaturePrefixFont = signaturePrefixFont + self.documentContentFont = documentContentFont + self.headerTitleFont = headerTitleFont + self.headerExportTimeStampFont = headerExportTimeStampFont + } } #endif @@ -126,8 +126,8 @@ extension ConsentDocument { let consentTitle: LocalizedStringResource let paperSize: PaperSize let includingTimestamp: Bool - let fontSettings: FontSettings - + nonisolated(unsafe) let fontSettings: FontSettings + /// Creates an `ExportConfiguration` specifying the properties of the exported consent form. /// - Parameters: diff --git a/Sources/SpeziOnboarding/ConsentView/ExportConfiguration+Defaults.swift b/Sources/SpeziOnboarding/ConsentView/ExportConfiguration+Defaults.swift index bac4249..b084a1b 100644 --- a/Sources/SpeziOnboarding/ConsentView/ExportConfiguration+Defaults.swift +++ b/Sources/SpeziOnboarding/ConsentView/ExportConfiguration+Defaults.swift @@ -9,55 +9,56 @@ import Foundation import SwiftUI + extension ConsentDocument.ExportConfiguration { /// Provides default values for fields related to the `ConsentDocumentExportConfiguration`. public enum Defaults { - #if !os(macOS) - /// Default export font settings with fixed font sizes, ensuring a consistent appearance across platforms. - /// - /// This configuration uses `systemFont` and `boldSystemFont` with absolute font sizes to achieve uniform font sizes - /// on different operating systems such as macOS, iOS, and visionOS. - public static let defaultExportFontSettings = FontSettings( - signatureNameFont: UIFont.systemFont(ofSize: 10), - signaturePrefixFont: UIFont.boldSystemFont(ofSize: 12), - documentContentFont: UIFont.systemFont(ofSize: 12), - headerTitleFont: UIFont.boldSystemFont(ofSize: 28), - headerExportTimeStampFont: UIFont.systemFont(ofSize: 8) - ) - - /// Default font based on system standards. In contrast to defaultExportFontSettings, - /// the font sizes might change according to the system settings, potentially leading to varying exported PDF documents - /// on devices with different system settings (e.g., larger default font size). - public static let defaultSystemDefaultFontSettings = FontSettings( - signatureNameFont: UIFont.preferredFont(forTextStyle: .subheadline), - signaturePrefixFont: UIFont.preferredFont(forTextStyle: .title2), - documentContentFont: UIFont.preferredFont(forTextStyle: .body), - headerTitleFont: UIFont.boldSystemFont(ofSize: UIFont.preferredFont(forTextStyle: .largeTitle).pointSize), - headerExportTimeStampFont: UIFont.preferredFont(forTextStyle: .caption1) - ) - #else - /// Default export font settings with fixed font sizes, ensuring a consistent appearance across platforms. - /// - /// This configuration uses `systemFont` and `boldSystemFont` with absolute font sizes to achieve uniform font sizes - /// on different operating systems such as macOS, iOS, and visionOS. - public static let defaultExportFontSettings = FontSettings( - signatureNameFont: NSFont.systemFont(ofSize: 10), - signaturePrefixFont: NSFont.boldSystemFont(ofSize: 12), - documentContentFont: NSFont.systemFont(ofSize: 12), - headerTitleFont: NSFont.boldSystemFont(ofSize: 28), - headerExportTimeStampFont: NSFont.systemFont(ofSize: 8) - ) - - /// Default font based on system standards. In contrast to defaultExportFontSettings, - /// the font sizes might change according to the system settings, potentially leading to varying exported PDF documents - /// on devices with different system settings (e.g., larger default font size). - public static let defaultSystemDefaultFontSettings = FontSettings( - signatureNameFont: NSFont.preferredFont(forTextStyle: .subheadline), - signaturePrefixFont: NSFont.preferredFont(forTextStyle: .title2), - documentContentFont: NSFont.preferredFont(forTextStyle: .body), - headerTitleFont: NSFont.boldSystemFont(ofSize: NSFont.preferredFont(forTextStyle: .largeTitle).pointSize), - headerExportTimeStampFont: NSFont.preferredFont(forTextStyle: .caption1) - ) - #endif - } + #if !os(macOS) + /// Default export font settings with fixed font sizes, ensuring a consistent appearance across platforms. + /// + /// This configuration uses `systemFont` and `boldSystemFont` with absolute font sizes to achieve uniform font sizes + /// on different operating systems such as macOS, iOS, and visionOS. + public static let defaultExportFontSettings = FontSettings( + signatureNameFont: UIFont.systemFont(ofSize: 10), + signaturePrefixFont: UIFont.boldSystemFont(ofSize: 12), + documentContentFont: UIFont.systemFont(ofSize: 12), + headerTitleFont: UIFont.boldSystemFont(ofSize: 28), + headerExportTimeStampFont: UIFont.systemFont(ofSize: 8) + ) + + /// Default font based on system standards. In contrast to defaultExportFontSettings, + /// the font sizes might change according to the system settings, potentially leading to varying exported PDF documents + /// on devices with different system settings (e.g., larger default font size). + public static let defaultSystemDefaultFontSettings = FontSettings( + signatureNameFont: UIFont.preferredFont(forTextStyle: .subheadline), + signaturePrefixFont: UIFont.preferredFont(forTextStyle: .title2), + documentContentFont: UIFont.preferredFont(forTextStyle: .body), + headerTitleFont: UIFont.boldSystemFont(ofSize: UIFont.preferredFont(forTextStyle: .largeTitle).pointSize), + headerExportTimeStampFont: UIFont.preferredFont(forTextStyle: .caption1) + ) + #else + /// Default export font settings with fixed font sizes, ensuring a consistent appearance across platforms. + /// + /// This configuration uses `systemFont` and `boldSystemFont` with absolute font sizes to achieve uniform font sizes + /// on different operating systems such as macOS, iOS, and visionOS. + public static let defaultExportFontSettings = FontSettings( + signatureNameFont: NSFont.systemFont(ofSize: 10), + signaturePrefixFont: NSFont.boldSystemFont(ofSize: 12), + documentContentFont: NSFont.systemFont(ofSize: 12), + headerTitleFont: NSFont.boldSystemFont(ofSize: 28), + headerExportTimeStampFont: NSFont.systemFont(ofSize: 8) + ) + + /// Default font based on system standards. In contrast to defaultExportFontSettings, + /// the font sizes might change according to the system settings, potentially leading to varying exported PDF documents + /// on devices with different system settings (e.g., larger default font size). + public static let defaultSystemDefaultFontSettings = FontSettings( + signatureNameFont: NSFont.preferredFont(forTextStyle: .subheadline), + signaturePrefixFont: NSFont.preferredFont(forTextStyle: .title2), + documentContentFont: NSFont.preferredFont(forTextStyle: .body), + headerTitleFont: NSFont.boldSystemFont(ofSize: NSFont.preferredFont(forTextStyle: .largeTitle).pointSize), + headerExportTimeStampFont: NSFont.preferredFont(forTextStyle: .caption1) + ) + #endif + } } From e1384878edeb4e67b8fd35cf200b8c56567561c0 Mon Sep 17 00:00:00 2001 From: Philipp Zagar Date: Wed, 22 Jan 2025 00:33:43 +0100 Subject: [PATCH 7/7] fix --- .../ConsentView/ConsentDocument+ExportConfiguration.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+ExportConfiguration.swift b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+ExportConfiguration.swift index a5794dd..51a9732 100644 --- a/Sources/SpeziOnboarding/ConsentView/ConsentDocument+ExportConfiguration.swift +++ b/Sources/SpeziOnboarding/ConsentView/ConsentDocument+ExportConfiguration.swift @@ -126,7 +126,7 @@ extension ConsentDocument { let consentTitle: LocalizedStringResource let paperSize: PaperSize let includingTimestamp: Bool - nonisolated(unsafe) let fontSettings: FontSettings + let fontSettings: FontSettings /// Creates an `ExportConfiguration` specifying the properties of the exported consent form.