Skip to content

Commit 5b3df04

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 591dc82 commit 5b3df04

File tree

4 files changed

+228
-3
lines changed

4 files changed

+228
-3
lines changed

Package.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ let package = Package(
5454
name: "TestingTests",
5555
dependencies: [
5656
"Testing",
57+
"_Testing_AppKit",
5758
"_Testing_CoreGraphics",
5859
"_Testing_Foundation",
5960
],
@@ -95,6 +96,15 @@ let package = Package(
9596
),
9697

9798
// Cross-import overlays (not supported by Swift Package Manager)
99+
.target(
100+
name: "_Testing_AppKit",
101+
dependencies: [
102+
"Testing",
103+
"_Testing_CoreGraphics",
104+
],
105+
path: "Sources/Overlays/_Testing_AppKit",
106+
swiftSettings: .packageSettings
107+
),
98108
.target(
99109
name: "_Testing_CoreGraphics",
100110
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: 111 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,18 @@
1010

1111
@testable @_spi(Experimental) @_spi(ForToolsIntegrationOnly) import Testing
1212
private import _TestingInternals
13-
#if canImport(Foundation)
14-
import Foundation
15-
@_spi(Experimental) import _Testing_Foundation
13+
#if canImport(AppKit)
14+
import AppKit
15+
@_spi(Experimental) import _Testing_AppKit
1616
#endif
1717
#if canImport(CoreGraphics)
1818
import CoreGraphics
1919
@_spi(Experimental) @_spi(ForSwiftTestingOnly) import _Testing_CoreGraphics
2020
#endif
21+
#if canImport(Foundation)
22+
import Foundation
23+
@_spi(Experimental) import _Testing_Foundation
24+
#endif
2125
#if canImport(UniformTypeIdentifiers)
2226
import UniformTypeIdentifiers
2327
#endif
@@ -555,6 +559,71 @@ extension AttachmentTests {
555559
}
556560
}
557561
#endif
562+
563+
#if canImport(AppKit)
564+
static var nsImage: NSImage {
565+
get throws {
566+
let cgImage = try cgImage.get()
567+
let size = CGSize(width: CGFloat(cgImage.width), height: CGFloat(cgImage.height))
568+
return NSImage(cgImage: cgImage, size: size)
569+
}
570+
}
571+
572+
@available(_uttypesAPI, *)
573+
@Test func attachNSImage() throws {
574+
let image = try Self.nsImage
575+
let attachment = Attachment(image, named: "diamond.jpg")
576+
#expect(attachment.attachableValue.size == image.size) // NSImage makes a copy
577+
try attachment.attachableValue.withUnsafeBufferPointer(for: attachment) { buffer in
578+
#expect(buffer.count > 32)
579+
}
580+
}
581+
582+
@available(_uttypesAPI, *)
583+
@Test func attachNSImageWithCustomRep() throws {
584+
let image = NSImage(size: NSSize(width: 32.0, height: 32.0), flipped: false) { rect in
585+
NSColor.red.setFill()
586+
rect.fill()
587+
return true
588+
}
589+
let attachment = Attachment(image, named: "diamond.jpg")
590+
#expect(attachment.attachableValue.size == image.size) // NSImage makes a copy
591+
try attachment.attachableValue.withUnsafeBufferPointer(for: attachment) { buffer in
592+
#expect(buffer.count > 32)
593+
}
594+
}
595+
596+
@available(_uttypesAPI, *)
597+
@Test func attachNSImageWithSubclassedNSImage() throws {
598+
let image = MyImage(size: NSSize(width: 32.0, height: 32.0))
599+
image.addRepresentation(NSCustomImageRep(size: image.size, flipped: false) { rect in
600+
NSColor.green.setFill()
601+
rect.fill()
602+
return true
603+
})
604+
605+
let attachment = Attachment(image, named: "diamond.jpg")
606+
#expect(attachment.attachableValue === image)
607+
#expect(attachment.attachableValue.size == image.size) // NSImage makes a copy
608+
try attachment.attachableValue.withUnsafeBufferPointer(for: attachment) { buffer in
609+
#expect(buffer.count > 32)
610+
}
611+
}
612+
613+
@available(_uttypesAPI, *)
614+
@Test func attachNSImageWithSubclassedRep() throws {
615+
let image = NSImage(size: NSSize(width: 32.0, height: 32.0))
616+
image.addRepresentation(MyImageRep<Int>())
617+
618+
let attachment = Attachment(image, named: "diamond.jpg")
619+
#expect(attachment.attachableValue.size == image.size) // NSImage makes a copy
620+
let firstRep = try #require(attachment.attachableValue.representations.first)
621+
#expect(!(firstRep is MyImageRep<Int>))
622+
try attachment.attachableValue.withUnsafeBufferPointer(for: attachment) { buffer in
623+
#expect(buffer.count > 32)
624+
}
625+
}
626+
#endif
558627
#endif
559628
}
560629
}
@@ -644,3 +713,42 @@ final class MyCodableAndSecureCodingAttachable: NSObject, Codable, NSSecureCodin
644713
}
645714
}
646715
#endif
716+
717+
#if canImport(AppKit)
718+
private final class MyImage: NSImage {
719+
override init(size: NSSize) {
720+
super.init(size: size)
721+
}
722+
723+
required init(pasteboardPropertyList propertyList: Any, ofType type: NSPasteboard.PasteboardType) {
724+
fatalError("Unimplemented")
725+
}
726+
727+
required init(coder: NSCoder) {
728+
fatalError("Unimplemented")
729+
}
730+
731+
override func copy(with zone: NSZone?) -> Any {
732+
// Intentionally make a copy as NSImage instead of MyImage to exercise the
733+
// cast-failed code path in the overlay.
734+
NSImage()
735+
}
736+
}
737+
738+
private final class MyImageRep<T>: NSImageRep {
739+
override init() {
740+
super.init()
741+
size = NSSize(width: 32.0, height: 32.0)
742+
}
743+
744+
required init?(coder: NSCoder) {
745+
fatalError("Unimplemented")
746+
}
747+
748+
override func draw() -> Bool {
749+
NSColor.blue.setFill()
750+
NSRect(origin: .zero, size: size).fill()
751+
return true
752+
}
753+
}
754+
#endif

0 commit comments

Comments
 (0)