Skip to content

Commit 49099a2

Browse files
authored
Add a cross-import overlay with AppKit to allow attaching NSImages. (#869)
This PR adds on to the Core Graphics cross-import overlay added in #827 to allow attaching instances of `NSImage` to a test. `NSImage` is a more complicated animal because it is not `Sendable`, but we don't want to make a (potentially very expensive) deep copy of its data until absolutely necessary. So we check inside the image to see if its contained representations are known to be safely copyable (i.e. copies made with `NSCopying` do not share any mutable state with their originals.) If it looks safe to make a copy of the image by calling `copy()`, we do so; otherwise, we try to make a deep copy of the image. Due to how Swift implements polymorphism in protocol requirements, and because we don't really know what they're doing, subclasses of `NSImage` just get a call to `copy()` instead of deep introspection. `UIImage` support will be implemented in a separate PR. > [!NOTE] > Image attachments remain an experimental feature. ### Checklist: - [x] Code and documentation should follow the style of the [Style Guide](https://github.com/apple/swift-testing/blob/main/Documentation/StyleGuide.md). - [x] If public symbols are renamed or modified, DocC references should be updated.
1 parent 84ed952 commit 49099a2

File tree

7 files changed

+233
-0
lines changed

7 files changed

+233
-0
lines changed

Package.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ let package = Package(
125125
name: "TestingTests",
126126
dependencies: [
127127
"Testing",
128+
"_Testing_AppKit",
128129
"_Testing_CoreGraphics",
129130
"_Testing_Foundation",
130131
"MemorySafeTestingTests",
@@ -190,6 +191,15 @@ let package = Package(
190191
),
191192

192193
// Cross-import overlays (not supported by Swift Package Manager)
194+
.target(
195+
name: "_Testing_AppKit",
196+
dependencies: [
197+
"Testing",
198+
"_Testing_CoreGraphics",
199+
],
200+
path: "Sources/Overlays/_Testing_AppKit",
201+
swiftSettings: .packageSettings
202+
),
193203
.target(
194204
name: "_Testing_CoreGraphics",
195205
dependencies: [
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
//
2+
// This source file is part of the Swift.org open source project
3+
//
4+
// Copyright (c) 2024 Apple Inc. and the Swift project authors
5+
// Licensed under Apache License v2.0 with Runtime Library Exception
6+
//
7+
// See https://swift.org/LICENSE.txt for license information
8+
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
//
10+
11+
#if SWT_TARGET_OS_APPLE && canImport(AppKit)
12+
public import AppKit
13+
@_spi(ForSwiftTestingOnly) @_spi(Experimental) public import _Testing_CoreGraphics
14+
15+
@_spi(Experimental)
16+
extension NSImage: AttachableAsCGImage {
17+
public var attachableCGImage: CGImage {
18+
get throws {
19+
let ctm = AffineTransform(scale: _attachmentScaleFactor) as NSAffineTransform
20+
guard let result = cgImage(forProposedRect: nil, context: nil, hints: [.ctm: ctm]) else {
21+
throw ImageAttachmentError.couldNotCreateCGImage
22+
}
23+
return result
24+
}
25+
}
26+
27+
public var _attachmentScaleFactor: CGFloat {
28+
let maxRepWidth = representations.lazy
29+
.map { CGFloat($0.pixelsWide) / $0.size.width }
30+
.filter { $0 > 0.0 }
31+
.max()
32+
return maxRepWidth ?? 1.0
33+
}
34+
35+
/// Get the base address of the loaded image containing `class`.
36+
///
37+
/// - Parameters:
38+
/// - class: The class to look for.
39+
///
40+
/// - Returns: The base address of the image containing `class`, or `nil` if
41+
/// no image was found (for instance, if the class is generic or dynamically
42+
/// generated.)
43+
///
44+
/// "Image" in this context refers to a binary/executable image.
45+
private static func _baseAddressOfImage(containing `class`: AnyClass) -> UnsafeRawPointer? {
46+
let classAsAddress = Unmanaged.passUnretained(`class` as AnyObject).toOpaque()
47+
48+
var info = Dl_info()
49+
guard 0 != dladdr(classAsAddress, &info) else {
50+
return nil
51+
}
52+
return .init(info.dli_fbase)
53+
}
54+
55+
/// The base address of the image containing AppKit's symbols, if known.
56+
private static nonisolated(unsafe) let _appKitBaseAddress = _baseAddressOfImage(containing: NSImageRep.self)
57+
58+
public func _makeCopyForAttachment() -> Self {
59+
// If this image is of an NSImage subclass, we cannot reliably make a deep
60+
// copy of it because we don't know what its `init(data:)` implementation
61+
// might do. Try to make a copy (using NSCopying), but if that doesn't work
62+
// then just return `self` verbatim.
63+
//
64+
// Third-party NSImage subclasses are presumably rare in the wild, so
65+
// hopefully this case doesn't pop up too often.
66+
guard isMember(of: NSImage.self) else {
67+
return self.copy() as? Self ?? self
68+
}
69+
70+
// Check whether the image contains any representations that we don't think
71+
// are safe. If it does, then make a "safe" copy.
72+
let allImageRepsAreSafe = representations.allSatisfy { imageRep in
73+
// NSCustomImageRep includes an arbitrary rendering block that may not be
74+
// concurrency-safe in Swift.
75+
if imageRep is NSCustomImageRep {
76+
return false
77+
}
78+
79+
// Treat all other classes declared in AppKit as safe. We can't reason
80+
// about classes declared in other modules, so treat them all as if they
81+
// are unsafe.
82+
return Self._baseAddressOfImage(containing: type(of: imageRep)) == Self._appKitBaseAddress
83+
}
84+
if !allImageRepsAreSafe, let safeCopy = tiffRepresentation.flatMap(Self.init(data:)) {
85+
// Create a "safe" copy of this image by flattening it to TIFF and then
86+
// creating a new NSImage instance from it.
87+
return safeCopy
88+
}
89+
90+
// This image appears to be safe to copy directly. (This call should never
91+
// fail since we already know `self` is a direct instance of `NSImage`.)
92+
return unsafeDowncast(self.copy() as AnyObject, to: Self.self)
93+
}
94+
}
95+
#endif
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
//
2+
// This source file is part of the Swift.org open source project
3+
//
4+
// Copyright (c) 2024 Apple Inc. and the Swift project authors
5+
// Licensed under Apache License v2.0 with Runtime Library Exception
6+
//
7+
// See https://swift.org/LICENSE.txt for license information
8+
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
//
10+
11+
@_exported public import Testing
12+
@_exported public import _Testing_CoreGraphics

Sources/Overlays/_Testing_CoreGraphics/Attachments/AttachableAsCGImage.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ private import ImageIO
2424
/// be attached to a test:
2525
///
2626
/// - [`CGImage`](https://developer.apple.com/documentation/coregraphics/cgimage)
27+
/// - [`NSImage`](https://developer.apple.com/documentation/appkit/nsimage)
28+
/// (macOS)
2729
///
2830
/// You do not generally need to add your own conformances to this protocol. If
2931
/// you have an image in another format that needs to be attached to a test,

Sources/Overlays/_Testing_CoreGraphics/Attachments/Attachment+AttachableAsCGImage.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ extension Attachment {
3636
/// ``AttachableAsCGImage`` protocol and can be attached to a test:
3737
///
3838
/// - [`CGImage`](https://developer.apple.com/documentation/coregraphics/cgimage)
39+
/// - [`NSImage`](https://developer.apple.com/documentation/appkit/nsimage)
40+
/// (macOS)
3941
///
4042
/// The testing library uses the image format specified by `contentType`. Pass
4143
/// `nil` to let the testing library decide which image format to use. If you
@@ -80,6 +82,8 @@ extension Attachment {
8082
/// ``AttachableAsCGImage`` protocol and can be attached to a test:
8183
///
8284
/// - [`CGImage`](https://developer.apple.com/documentation/coregraphics/cgimage)
85+
/// - [`NSImage`](https://developer.apple.com/documentation/appkit/nsimage)
86+
/// (macOS)
8387
///
8488
/// The testing library uses the image format specified by `contentType`. Pass
8589
/// `nil` to let the testing library decide which image format to use. If you

Sources/Overlays/_Testing_CoreGraphics/Attachments/_AttachableImageWrapper.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ import UniformTypeIdentifiers
4747
/// to the ``AttachableAsCGImage`` protocol and can be attached to a test:
4848
///
4949
/// - [`CGImage`](https://developer.apple.com/documentation/coregraphics/cgimage)
50+
/// - [`NSImage`](https://developer.apple.com/documentation/appkit/nsimage)
51+
/// (macOS)
5052
@_spi(Experimental)
5153
@available(_uttypesAPI, *)
5254
public struct _AttachableImageWrapper<Image>: Sendable where Image: AttachableAsCGImage {

Tests/TestingTests/AttachmentTests.swift

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010

1111
@testable @_spi(ForToolsIntegrationOnly) import Testing
1212
private import _TestingInternals
13+
#if canImport(AppKit)
14+
import AppKit
15+
@_spi(Experimental) import _Testing_AppKit
16+
#endif
1317
#if canImport(Foundation)
1418
import Foundation
1519
import _Testing_Foundation
@@ -577,6 +581,71 @@ extension AttachmentTests {
577581
}
578582
}
579583
#endif
584+
585+
#if canImport(AppKit)
586+
static var nsImage: NSImage {
587+
get throws {
588+
let cgImage = try cgImage.get()
589+
let size = CGSize(width: CGFloat(cgImage.width), height: CGFloat(cgImage.height))
590+
return NSImage(cgImage: cgImage, size: size)
591+
}
592+
}
593+
594+
@available(_uttypesAPI, *)
595+
@Test func attachNSImage() throws {
596+
let image = try Self.nsImage
597+
let attachment = Attachment(image, named: "diamond.jpg")
598+
#expect(attachment.attachableValue.size == image.size) // NSImage makes a copy
599+
try attachment.attachableValue.withUnsafeBytes(for: attachment) { buffer in
600+
#expect(buffer.count > 32)
601+
}
602+
}
603+
604+
@available(_uttypesAPI, *)
605+
@Test func attachNSImageWithCustomRep() throws {
606+
let image = NSImage(size: NSSize(width: 32.0, height: 32.0), flipped: false) { rect in
607+
NSColor.red.setFill()
608+
rect.fill()
609+
return true
610+
}
611+
let attachment = Attachment(image, named: "diamond.jpg")
612+
#expect(attachment.attachableValue.size == image.size) // NSImage makes a copy
613+
try attachment.attachableValue.withUnsafeBytes(for: attachment) { buffer in
614+
#expect(buffer.count > 32)
615+
}
616+
}
617+
618+
@available(_uttypesAPI, *)
619+
@Test func attachNSImageWithSubclassedNSImage() throws {
620+
let image = MyImage(size: NSSize(width: 32.0, height: 32.0))
621+
image.addRepresentation(NSCustomImageRep(size: image.size, flipped: false) { rect in
622+
NSColor.green.setFill()
623+
rect.fill()
624+
return true
625+
})
626+
627+
let attachment = Attachment(image, named: "diamond.jpg")
628+
#expect(attachment.attachableValue === image)
629+
#expect(attachment.attachableValue.size == image.size) // NSImage makes a copy
630+
try attachment.attachableValue.withUnsafeBytes(for: attachment) { buffer in
631+
#expect(buffer.count > 32)
632+
}
633+
}
634+
635+
@available(_uttypesAPI, *)
636+
@Test func attachNSImageWithSubclassedRep() throws {
637+
let image = NSImage(size: NSSize(width: 32.0, height: 32.0))
638+
image.addRepresentation(MyImageRep<Int>())
639+
640+
let attachment = Attachment(image, named: "diamond.jpg")
641+
#expect(attachment.attachableValue.size == image.size) // NSImage makes a copy
642+
let firstRep = try #require(attachment.attachableValue.representations.first)
643+
#expect(!(firstRep is MyImageRep<Int>))
644+
try attachment.attachableValue.withUnsafeBytes(for: attachment) { buffer in
645+
#expect(buffer.count > 32)
646+
}
647+
}
648+
#endif
580649
#endif
581650
}
582651
}
@@ -666,3 +735,42 @@ final class MyCodableAndSecureCodingAttachable: NSObject, Codable, NSSecureCodin
666735
}
667736
}
668737
#endif
738+
739+
#if canImport(AppKit)
740+
private final class MyImage: NSImage {
741+
override init(size: NSSize) {
742+
super.init(size: size)
743+
}
744+
745+
required init(pasteboardPropertyList propertyList: Any, ofType type: NSPasteboard.PasteboardType) {
746+
fatalError("Unimplemented")
747+
}
748+
749+
required init(coder: NSCoder) {
750+
fatalError("Unimplemented")
751+
}
752+
753+
override func copy(with zone: NSZone?) -> Any {
754+
// Intentionally make a copy as NSImage instead of MyImage to exercise the
755+
// cast-failed code path in the overlay.
756+
NSImage()
757+
}
758+
}
759+
760+
private final class MyImageRep<T>: NSImageRep {
761+
override init() {
762+
super.init()
763+
size = NSSize(width: 32.0, height: 32.0)
764+
}
765+
766+
required init?(coder: NSCoder) {
767+
fatalError("Unimplemented")
768+
}
769+
770+
override func draw() -> Bool {
771+
NSColor.blue.setFill()
772+
NSRect(origin: .zero, size: size).fill()
773+
return true
774+
}
775+
}
776+
#endif

0 commit comments

Comments
 (0)