Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Ensure access to PDFDocument is always concurrency safe #58

Merged
merged 8 commits into from
Jan 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 1 addition & 10 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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",
Expand Down Expand Up @@ -47,7 +40,6 @@ let package = Package(
.product(name: "TPPDF", package: "TPPDF")
],
swiftSettings: [
swiftConcurrency,
.enableUpcomingFeature("ExistentialAny")
],
plugins: [] + swiftLintPlugin()
Expand All @@ -61,7 +53,6 @@ let package = Package(
.process("Resources/")
],
swiftSettings: [
swiftConcurrency,
.enableUpcomingFeature("ExistentialAny")
],
plugins: [] + swiftLintPlugin()
Expand Down
2 changes: 1 addition & 1 deletion Sources/SpeziOnboarding/ConsentConstraint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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

Expand All @@ -127,7 +127,7 @@ extension ConsentDocument {
let paperSize: PaperSize
let includingTimestamp: Bool
let fontSettings: FontSettings


/// Creates an `ExportConfiguration` specifying the properties of the exported consent form.
/// - Parameters:
Expand Down
4 changes: 2 additions & 2 deletions Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
private let familyNamePlaceholder: LocalizedStringResource

let documentExport: ConsentDocumentExport

@Environment(\.colorScheme) var colorScheme
@State var name = PersonNameComponents()
#if !os(macOS)
Expand All @@ -66,7 +66,7 @@
#if !os(macOS)
nameInputView
#else
// Need to wrap the `NameFieldRow` from SpeziViews into a SwiftUI `Form, otherwise the Label is omitted
// Need to wrap the `NameFieldRow` from SpeziViews into a SwiftUI `Form`, otherwise the Label is omitted

Check warning on line 69 in Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift

View check run for this annotation

Codecov / codecov/patch

Sources/SpeziOnboarding/ConsentView/ConsentDocument.swift#L69

Added line #L69 was not covered by tests
Form {
nameInputView
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
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.
Expand Down Expand Up @@ -57,7 +58,7 @@
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
)) ?? AttributedString(String(localized: "MARKDOWN_LOADING_ERROR", bundle: .module))

markdownString.font = exportConfiguration.fontSettings.documentContentFont

Check warning on line 61 in Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport+Export.swift

View workflow job for this annotation

GitHub Actions / Build and Test Swift Package macOS (Debug, SpeziOnboarding-macOS.xcresult, SpeziOnboarding-macOS.... / Test using xcodebuild or run fastlane

conformance of 'NSFont' to 'Sendable' is unavailable

Check warning on line 61 in Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport+Export.swift

View workflow job for this annotation

GitHub Actions / Build and Test Swift Package macOS (Release, SpeziOnboarding-macOS-Release.xcresult, SpeziOnboard... / Test using xcodebuild or run fastlane

conformance of 'NSFont' to 'Sendable' is unavailable

return PDFAttributedText(text: NSAttributedString(markdownString))
}
Expand Down
54 changes: 28 additions & 26 deletions Sources/SpeziOnboarding/ConsentView/ConsentDocumentExport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@
import PencilKit
import SwiftUI


/// A type representing an exported `ConsentDocument`. It holds the exported `PDFDocument` and the corresponding document identifier String.
@Observable
public final class ConsentDocumentExport: Equatable, Sendable {
public final class ConsentDocumentExport: Equatable {
/// 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"
}
Expand All @@ -25,9 +26,10 @@ public final class ConsentDocumentExport: Equatable, Sendable {
var 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 name of the person which signed the document.
public var name = PersonNameComponents()
#if !os(macOS)
Expand All @@ -40,48 +42,48 @@ public final class ConsentDocumentExport: Equatable, Sendable {
public var signature = String()
#endif

/// 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.
@MainActor public var pdf: PDFDocument {
get async {
if let pdf = cachedPDF {
return pdf
}

guard let pdf = try? await export() else {
return .init()
}

cachedPDF = pdf
return pdf
}
}



/// 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.
/// - documentIdentfier: A unique String identifying the exported `ConsentDocument`.
/// - 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: PDFDocument? = nil
cachedPDF: sending PDFDocument? = nil
) {
self.asyncMarkdown = markdown
self.exportConfiguration = exportConfiguration
self.documentIdentifier = documentIdentifier
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/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() -> 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.take() ?? .init()
return cachedPDF
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion Sources/SpeziOnboarding/OnboardingConstraint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading
Loading