Fix terminal Cmd+V clipboard payload handling (#1305)

* Add clipboard payload regression tests

* Fix terminal clipboard payload handling
This commit is contained in:
Lawrence Chen 2026-03-13 04:46:13 -07:00 committed by GitHub
parent f95a32ea52
commit e94daa0bcf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 169 additions and 32 deletions

View file

@ -75,6 +75,7 @@ private enum GhosttyPasteboardHelper {
)
private static let utf8PlainTextType = NSPasteboard.PasteboardType("public.utf8-plain-text")
private static let shellEscapeCharacters = "\\ ()[]{}<>\"'`!#$&;|*?\t"
private static let objectReplacementCharacter = Character(UnicodeScalar(0xFFFC)!)
static func pasteboard(for location: ghostty_clipboard_e) -> NSPasteboard? {
switch location {
@ -99,13 +100,35 @@ private enum GhosttyPasteboardHelper {
return value
}
return pasteboard.string(forType: utf8PlainTextType)
if let value = pasteboard.string(forType: utf8PlainTextType) {
return value
}
if hasImageData(in: pasteboard),
let html = pasteboard.string(forType: .html),
htmlHasNoVisibleText(html) {
return nil
}
if let htmlText = attributedStringContents(from: pasteboard, type: .html, documentType: .html) {
return htmlText
}
if let rtfText = attributedStringContents(from: pasteboard, type: .rtf, documentType: .rtf) {
return rtfText
}
return attributedStringContents(from: pasteboard, type: .rtfd, documentType: .rtfd)
}
static func hasString(for location: ghostty_clipboard_e) -> Bool {
guard let pasteboard = pasteboard(for: location) else { return false }
if let text = stringContents(from: pasteboard), !text.isEmpty { return true }
return clipboardHasImageOnly()
let types = pasteboard.types ?? []
if types.contains(.fileURL) || types.contains(.string) || types.contains(utf8PlainTextType)
|| types.contains(.html) || types.contains(.rtf) || types.contains(.rtfd) {
return true
}
return hasImageData(in: pasteboard)
}
static func writeString(_ string: String, to location: ghostty_clipboard_e) {
@ -122,40 +145,85 @@ private enum GhosttyPasteboardHelper {
return result
}
private static let maxClipboardImageSize = 10 * 1024 * 1024 // 10 MB
private static func attributedStringContents(
from pasteboard: NSPasteboard,
type: NSPasteboard.PasteboardType,
documentType: NSAttributedString.DocumentType
) -> String? {
let data =
pasteboard.data(forType: type)
?? pasteboard.string(forType: type)?.data(using: .utf8)
guard let data else { return nil }
/// Quick check: does the clipboard have image data and no text?
static func clipboardHasImageOnly() -> Bool {
let pb = NSPasteboard.general
let types = pb.types ?? []
let hasText = types.contains(.string) || types.contains(.html)
|| types.contains(.rtf) || types.contains(.rtfd)
if hasText { return false }
let attributed = try? NSAttributedString(
data: data,
options: [
.documentType: documentType,
.characterEncoding: String.Encoding.utf8.rawValue
],
documentAttributes: nil
)
let sanitized = attributed?.string
.split(separator: objectReplacementCharacter, omittingEmptySubsequences: false)
.joined(separator: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard let sanitized, !sanitized.isEmpty else { return nil }
return sanitized
}
private static func hasImageData(in pasteboard: NSPasteboard) -> Bool {
let types = pasteboard.types ?? []
return types.contains(.tiff) || types.contains(.png)
}
/// When the clipboard contains only image data (no text/HTML), saves it as
/// a temporary PNG file and returns the shell-escaped file path. Returns nil
/// if the clipboard contains text or no image.
static func saveClipboardImageIfNeeded() -> String? {
let pb = NSPasteboard.general
let types = pb.types ?? []
private static func htmlHasNoVisibleText(_ html: String) -> Bool {
let withoutComments = html.replacingOccurrences(
of: "<!--[\\s\\S]*?-->",
with: " ",
options: .regularExpression
)
let withoutTags = withoutComments.replacingOccurrences(
of: "<[^>]+>",
with: " ",
options: .regularExpression
)
let normalized = withoutTags
.replacingOccurrences(of: "&nbsp;", with: " ")
.replacingOccurrences(of: "&#160;", with: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
return normalized.isEmpty
}
// If pasteboard has text/HTML, this is a normal copy.
let hasText = types.contains(.string) || types.contains(.html)
|| types.contains(.rtf) || types.contains(.rtfd)
if hasText { return nil }
/// When the clipboard contains only image data (or rich text that resolves to
/// an attachment-only image), saves it as a temporary PNG file and returns the
/// shell-escaped file path. Returns nil if the clipboard contains text or no image.
static func saveClipboardImageIfNeeded(
from pasteboard: NSPasteboard = .general,
assumeNoText: Bool = false
) -> String? {
if !assumeNoText && stringContents(from: pasteboard) != nil { return nil }
// Check for image types (TIFF from screenshots, PNG from some tools).
guard types.contains(.tiff) || types.contains(.png) else { return nil }
guard let image = NSImage(pasteboard: pb),
let tiffData = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiffData),
let pngData = bitmap.representation(using: .png, properties: [:]) else { return nil }
let imageData: Data
let fileExtension: String
if let pngData = pasteboard.data(forType: .png) {
imageData = pngData
fileExtension = "png"
} else {
guard hasImageData(in: pasteboard),
let image = NSImage(pasteboard: pasteboard),
let tiffData = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiffData),
let pngData = bitmap.representation(using: .png, properties: [:]) else { return nil }
imageData = pngData
fileExtension = "png"
}
guard pngData.count <= maxClipboardImageSize else {
let maxClipboardImageSize = 10 * 1024 * 1024 // 10 MB
guard imageData.count <= maxClipboardImageSize else {
#if DEBUG
dlog("terminal.paste.image.rejected reason=tooLarge bytes=\(pngData.count)")
dlog("terminal.paste.image.rejected reason=tooLarge bytes=\(imageData.count)")
#endif
return nil
}
@ -164,11 +232,11 @@ private enum GhosttyPasteboardHelper {
formatter.dateFormat = "yyyy-MM-dd-HHmmss"
formatter.locale = Locale(identifier: "en_US_POSIX")
let timestamp = formatter.string(from: Date())
let filename = "clipboard-\(timestamp)-\(UUID().uuidString.prefix(8)).png"
let filename = "clipboard-\(timestamp)-\(UUID().uuidString.prefix(8)).\(fileExtension)"
let path = (NSTemporaryDirectory() as NSString).appendingPathComponent(filename)
do {
try pngData.write(to: URL(fileURLWithPath: path))
try imageData.write(to: URL(fileURLWithPath: path))
} catch {
#if DEBUG
dlog("terminal.paste.image.writeFailed error=\(error.localizedDescription)")
@ -180,6 +248,16 @@ private enum GhosttyPasteboardHelper {
}
}
#if DEBUG
func cmuxPasteboardStringContentsForTesting(_ pasteboard: NSPasteboard) -> String? {
GhosttyPasteboardHelper.stringContents(from: pasteboard)
}
func cmuxPasteboardImagePathForTesting(_ pasteboard: NSPasteboard) -> String? {
GhosttyPasteboardHelper.saveClipboardImageIfNeeded(from: pasteboard)
}
#endif
enum TerminalOpenURLTarget: Equatable {
case embeddedBrowser(URL)
case external(URL)
@ -877,7 +955,11 @@ class GhosttyApp {
// When clipboard has only image data (e.g. screenshot), save as temp
// PNG and paste the file path so CLI tools can receive images.
if value.isEmpty, let imagePath = GhosttyPasteboardHelper.saveClipboardImageIfNeeded() {
if value.isEmpty,
let imagePath = pasteboard.flatMap({
GhosttyPasteboardHelper.saveClipboardImageIfNeeded(from: $0, assumeNoText: true)
})
{
value = imagePath
}

View file

@ -872,6 +872,61 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase {
}
}
@MainActor
final class GhosttyPasteboardHelperTests: XCTestCase {
func testHTMLOnlyPasteboardExtractsPlainText() {
let pasteboard = NSPasteboard(name: .init("cmux-test-html-\(UUID().uuidString)"))
pasteboard.clearContents()
pasteboard.setString("<p>Hello <strong>world</strong></p>", forType: .html)
XCTAssertEqual(cmuxPasteboardStringContentsForTesting(pasteboard), "Hello world")
XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard))
}
func testImageHTMLClipboardFallsBackToImagePath() throws {
let pasteboard = NSPasteboard(name: .init("cmux-test-image-html-\(UUID().uuidString)"))
pasteboard.clearContents()
pasteboard.setString("<meta charset='utf-8'><img src=\"https://example.com/keyboard.png\">", forType: .html)
let image = NSImage(size: NSSize(width: 1, height: 1))
image.lockFocus()
NSColor.red.setFill()
NSRect(x: 0, y: 0, width: 1, height: 1).fill()
image.unlockFocus()
let tiffData = try XCTUnwrap(image.tiffRepresentation)
let bitmap = try XCTUnwrap(NSBitmapImageRep(data: tiffData))
let pngData = try XCTUnwrap(bitmap.representation(using: .png, properties: [:]))
pasteboard.setData(pngData, forType: .png)
XCTAssertNil(cmuxPasteboardStringContentsForTesting(pasteboard))
let imagePath = try XCTUnwrap(cmuxPasteboardImagePathForTesting(pasteboard))
defer { try? FileManager.default.removeItem(atPath: imagePath) }
XCTAssertTrue(imagePath.hasSuffix(".png"))
XCTAssertTrue(FileManager.default.fileExists(atPath: imagePath))
}
func testImageHTMLClipboardWithVisibleTextPrefersText() throws {
let pasteboard = NSPasteboard(name: .init("cmux-test-image-html-text-\(UUID().uuidString)"))
pasteboard.clearContents()
pasteboard.setString("<p>Hello <img src=\"https://example.com/keyboard.png\"></p>", forType: .html)
let image = NSImage(size: NSSize(width: 1, height: 1))
image.lockFocus()
NSColor.blue.setFill()
NSRect(x: 0, y: 0, width: 1, height: 1).fill()
image.unlockFocus()
let tiffData = try XCTUnwrap(image.tiffRepresentation)
let bitmap = try XCTUnwrap(NSBitmapImageRep(data: tiffData))
let pngData = try XCTUnwrap(bitmap.representation(using: .png, properties: [:]))
pasteboard.setData(pngData, forType: .png)
XCTAssertEqual(cmuxPasteboardStringContentsForTesting(pasteboard), "Hello")
XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard))
}
}
@MainActor
final class AppDelegateWindowContextRoutingTests: XCTestCase {
private func makeMainWindow(id: UUID) -> NSWindow {