diff --git a/Sources/FoundationEssentials/AttributedString/AttributedStringAttributeStorage.swift b/Sources/FoundationEssentials/AttributedString/AttributedStringAttributeStorage.swift index 0f9521de1..d10c84ff8 100644 --- a/Sources/FoundationEssentials/AttributedString/AttributedStringAttributeStorage.swift +++ b/Sources/FoundationEssentials/AttributedString/AttributedStringAttributeStorage.swift @@ -32,6 +32,21 @@ extension AttributedString { inheritedByAddedText = K.inheritedByAddedText invalidationConditions = K.invalidationConditions } + + #if FOUNDATION_FRAMEWORK + @inline(__always) + private static func _unsafeAssumeSendableRawValue(_ value: T) -> RawValue { + // Perform this cast in a separate function unaware of the T: Hashable constraint to avoid compiler warnings when performing the Hashable --> Hashable & Sendable cast + value as! RawValue + } + + fileprivate init(assumingSendable value: K.Value, for key: K.Type) { + _rawValue = Self._unsafeAssumeSendableRawValue(value) + runBoundaries = K.runBoundaries + inheritedByAddedText = K.inheritedByAddedText + invalidationConditions = K.invalidationConditions + } + #endif var isInvalidatedOnTextChange: Bool { invalidationConditions?.contains(.textChanged) ?? false @@ -61,6 +76,18 @@ extension AttributedString { return value } + #if FOUNDATION_FRAMEWORK + fileprivate func rawValueAssumingSendable( + as key: K.Type + ) -> K.Value { + // Dynamic cast instead of an identity cast to support bridging between attribute value types like NSColor/UIColor + guard let value = self._rawValue as? K.Value else { + preconditionFailure("Unable to read \(K.self) attribute: stored value of type \(type(of: self._rawValue)) is not key's value type (\(K.Value.self))") + } + return value + } + #endif + static func ==(left: Self, right: Self) -> Bool { func openEquatableLHS(_ leftValue: LeftValue) -> Bool { func openEquatableRHS(_ rightValue: RightValue) -> Bool { @@ -193,6 +220,25 @@ extension AttributedString._AttributeStorage { get { self[T.name]?.rawValue(as: T.self) } set { self[T.name] = .wrapIfPresent(newValue, for: T.self) } } + + #if FOUNDATION_FRAMEWORK + /// Stores & retrieves an attribute value bypassing the T.Value : Sendable constraint + /// + /// In general, callers should _always_ use the subscript that contains a T.Value : Sendable constraint + /// This subscript should only be used in contexts when callers are forced to work around the lack of an AttributedStringKey.Value : Sendable constraint and assume the values are Sendable (ex. during NSAttributedString conversion while iterating scopes) + subscript (assumingSendable attribute: T.Type) -> T.Value? { + get { + self[T.name]?.rawValueAssumingSendable(as: T.self) + } + set { + guard let newValue else { + self[T.name] = nil + return + } + self[T.name] = _AttributeValue(assumingSendable: newValue, for: T.self) + } + } + #endif subscript (_ attributeName: String) -> _AttributeValue? { get { self.contents[attributeName] } diff --git a/Sources/FoundationEssentials/AttributedString/AttributedStringCodable.swift b/Sources/FoundationEssentials/AttributedString/AttributedStringCodable.swift index db82eca5e..33f28ab47 100644 --- a/Sources/FoundationEssentials/AttributedString/AttributedStringCodable.swift +++ b/Sources/FoundationEssentials/AttributedString/AttributedStringCodable.swift @@ -228,7 +228,8 @@ extension AttributedString : CodableWithConfiguration { { let attributeEncoder = attributesContainer.superEncoder(forKey: AttributeKey(stringValue: name)!) func project(_: K.Type) throws { - try K.encode(attributes[K.self]!, to: attributeEncoder) + // We must assume that the value is Sendable here because we are dynamically iterating a scope and the attribute keys do not statically declare the values are Sendable + try K.encode(attributes[assumingSendable: K.self]!, to: attributeEncoder) } try project(encodableAttributeType) } // else: the attribute was not in the provided scope or was not encodable, so drop it @@ -336,7 +337,8 @@ extension AttributedString : CodableWithConfiguration { let decodableAttributeType = attributeKeyType as? any DecodableAttributedStringKey.Type { func project(_: K.Type) throws { - attributes[K.self] = try K.decode(from: try attributesContainer.superDecoder(forKey: key)) + // We must assume that the value is Sendable here because we are dynamically iterating a scope and the attribute keys do not statically declare the values are Sendable + attributes[assumingSendable: K.self] = try K.decode(from: try attributesContainer.superDecoder(forKey: key)) } try project(decodableAttributeType) } diff --git a/Sources/FoundationEssentials/AttributedString/Conversion.swift b/Sources/FoundationEssentials/AttributedString/Conversion.swift index c486e951a..fad6aba49 100644 --- a/Sources/FoundationEssentials/AttributedString/Conversion.swift +++ b/Sources/FoundationEssentials/AttributedString/Conversion.swift @@ -111,7 +111,8 @@ extension AttributeContainer { for (key, value) in dictionary { if let type = attributeTable[key.rawValue] { func project(_: K.Type) throws { - storage[K.self] = try K._convertFromObjectiveCValue(value as AnyObject) + // We must assume that the value is Sendable here because we are dynamically iterating a scope and the attribute keys do not statically declare the values are Sendable + storage[assumingSendable: K.self] = try K._convertFromObjectiveCValue(value as AnyObject) } do { try project(type) @@ -145,7 +146,8 @@ extension Dictionary where Key == NSAttributedString.Key, Value == Any { for key in container.storage.keys { if let type = attributeTable[key] { func project(_: K.Type) throws { - self[NSAttributedString.Key(rawValue: key)] = try K._convertToObjectiveCValue(container.storage[K.self]!) + // We must assume that the value is Sendable here because we are dynamically iterating a scope and the attribute keys do not statically declare the values are Sendable + self[NSAttributedString.Key(rawValue: key)] = try K._convertToObjectiveCValue(container.storage[assumingSendable: K.self]!) } do { try project(type)