Skip to content

Commit 122e117

Browse files
committed
Add a cross-import overlay with AppKit to allow attaching NSImages.
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] > Attachments remain an experimental feature.
1 parent 17c00b0 commit 122e117

File tree

4 files changed

+229
-0
lines changed

4 files changed

+229
-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
],
@@ -175,6 +176,15 @@ let package = Package(
175176
),
176177

177178
// Cross-import overlays (not supported by Swift Package Manager)
179+
.target(
180+
name: "_Testing_AppKit",
181+
dependencies: [
182+
"Testing",
183+
"_Testing_CoreGraphics",
184+
],
185+
path: "Sources/Overlays/_Testing_AppKit",
186+
swiftSettings: .packageSettings
187+
),
178188
.target(
179189
name: "_Testing_CoreGraphics",
180190
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

Tests/TestingTests/AttachmentTests.swift

Lines changed: 112 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
@@ -18,6 +22,10 @@ import _Testing_Foundation
1822
import CoreGraphics
1923
@_spi(Experimental) import _Testing_CoreGraphics
2024
#endif
25+
#if canImport(Foundation)
26+
import Foundation
27+
@_spi(Experimental) import _Testing_Foundation
28+
#endif
2129
#if canImport(UniformTypeIdentifiers)
2230
import UniformTypeIdentifiers
2331
#endif
@@ -560,6 +568,71 @@ extension AttachmentTests {
560568
}
561569
}
562570
#endif
571+
572+
#if canImport(AppKit)
573+
static var nsImage: NSImage {
574+
get throws {
575+
let cgImage = try cgImage.get()
576+
let size = CGSize(width: CGFloat(cgImage.width), height: CGFloat(cgImage.height))
577+
return NSImage(cgImage: cgImage, size: size)
578+
}
579+
}
580+
581+
@available(_uttypesAPI, *)
582+
@Test func attachNSImage() throws {
583+
let image = try Self.nsImage
584+
let attachment = Attachment(image, named: "diamond.jpg")
585+
#expect(attachment.attachableValue.size == image.size) // NSImage makes a copy
586+
try attachment.attachableValue.withUnsafeBufferPointer(for: attachment) { buffer in
587+
#expect(buffer.count > 32)
588+
}
589+
}
590+
591+
@available(_uttypesAPI, *)
592+
@Test func attachNSImageWithCustomRep() throws {
593+
let image = NSImage(size: NSSize(width: 32.0, height: 32.0), flipped: false) { rect in
594+
NSColor.red.setFill()
595+
rect.fill()
596+
return true
597+
}
598+
let attachment = Attachment(image, named: "diamond.jpg")
599+
#expect(attachment.attachableValue.size == image.size) // NSImage makes a copy
600+
try attachment.attachableValue.withUnsafeBufferPointer(for: attachment) { buffer in
601+
#expect(buffer.count > 32)
602+
}
603+
}
604+
605+
@available(_uttypesAPI, *)
606+
@Test func attachNSImageWithSubclassedNSImage() throws {
607+
let image = MyImage(size: NSSize(width: 32.0, height: 32.0))
608+
image.addRepresentation(NSCustomImageRep(size: image.size, flipped: false) { rect in
609+
NSColor.green.setFill()
610+
rect.fill()
611+
return true
612+
})
613+
614+
let attachment = Attachment(image, named: "diamond.jpg")
615+
#expect(attachment.attachableValue === image)
616+
#expect(attachment.attachableValue.size == image.size) // NSImage makes a copy
617+
try attachment.attachableValue.withUnsafeBufferPointer(for: attachment) { buffer in
618+
#expect(buffer.count > 32)
619+
}
620+
}
621+
622+
@available(_uttypesAPI, *)
623+
@Test func attachNSImageWithSubclassedRep() throws {
624+
let image = NSImage(size: NSSize(width: 32.0, height: 32.0))
625+
image.addRepresentation(MyImageRep<Int>())
626+
627+
let attachment = Attachment(image, named: "diamond.jpg")
628+
#expect(attachment.attachableValue.size == image.size) // NSImage makes a copy
629+
let firstRep = try #require(attachment.attachableValue.representations.first)
630+
#expect(!(firstRep is MyImageRep<Int>))
631+
try attachment.attachableValue.withUnsafeBufferPointer(for: attachment) { buffer in
632+
#expect(buffer.count > 32)
633+
}
634+
}
635+
#endif
563636
#endif
564637
}
565638
}
@@ -649,3 +722,42 @@ final class MyCodableAndSecureCodingAttachable: NSObject, Codable, NSSecureCodin
649722
}
650723
}
651724
#endif
725+
726+
#if canImport(AppKit)
727+
private final class MyImage: NSImage {
728+
override init(size: NSSize) {
729+
super.init(size: size)
730+
}
731+
732+
required init(pasteboardPropertyList propertyList: Any, ofType type: NSPasteboard.PasteboardType) {
733+
fatalError("Unimplemented")
734+
}
735+
736+
required init(coder: NSCoder) {
737+
fatalError("Unimplemented")
738+
}
739+
740+
override func copy(with zone: NSZone?) -> Any {
741+
// Intentionally make a copy as NSImage instead of MyImage to exercise the
742+
// cast-failed code path in the overlay.
743+
NSImage()
744+
}
745+
}
746+
747+
private final class MyImageRep<T>: NSImageRep {
748+
override init() {
749+
super.init()
750+
size = NSSize(width: 32.0, height: 32.0)
751+
}
752+
753+
required init?(coder: NSCoder) {
754+
fatalError("Unimplemented")
755+
}
756+
757+
override func draw() -> Bool {
758+
NSColor.blue.setFill()
759+
NSRect(origin: .zero, size: size).fill()
760+
return true
761+
}
762+
}
763+
#endif

0 commit comments

Comments
 (0)