Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 70 additions & 1 deletion macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ extension NSPasteboard {
/// Does these things in order:

@cubic-dev-ai cubic-dev-ai Bot Apr 20, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: NSAttributedString.string emits U+FFFC (Object Replacement Character) for every embedded image/attachment in RTF or HTML. Since 0xFFFC > 127, each one inflates nonASCIIScalarCount, which can cause an incorrect swap (e.g., mangled plain text with 0 non-ASCII vs. RTF with only an image → 1 > 0 triggers fallback). Worse, any U+FFFC that survives into the return value renders as garbage in the terminal.

Filter \u{FFFC} (and \u{FFFD}) out of the string returned by richTextFallback() before using it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift, line 101:

<comment>`NSAttributedString.string` emits `U+FFFC` (Object Replacement Character) for every embedded image/attachment in RTF or HTML. Since `0xFFFC > 127`, each one inflates `nonASCIIScalarCount`, which can cause an incorrect swap (e.g., mangled plain text with 0 non-ASCII vs. RTF with only an image → `1 > 0` triggers fallback). Worse, any `U+FFFC` that survives into the return value renders as garbage in the terminal.

Filter `\u{FFFC}` (and `\u{FFFD}`) out of the string returned by `richTextFallback()` before using it.</comment>

<file context>
@@ -45,7 +47,74 @@ extension NSPasteboard {
+               documentAttributes: nil
+           ),
+           !attr.string.isEmpty {
+            return attr.string
+        }
+
</file context>
Fix with Cubic

/// - Tries to get the absolute filesystem path of the file in the pasteboard if there is one and ensures the file path is properly escaped.
/// - Tries to get any string from the pasteboard.
/// - If the plain-text variant looks encoding-lossy, falls back to
/// extracting plain text from the RTF or HTML variant.
/// If all of the above fail, returns None.
func getOpinionatedStringContents() -> String? {
if let urls = readObjects(forClasses: [NSURL.self]) as? [URL],
Expand All @@ -45,7 +47,74 @@ extension NSPasteboard {
.joined(separator: " ")
}

return self.string(forType: .string)
let plainText = self.string(forType: .string)

// Some apps (certain IMs, Electron ports, legacy native apps) write a
// mangled plain-text variant alongside a correctly-encoded rich-text
// variant — e.g. non-ASCII chars arrive as U+FFFD or literal '?' runs
// because the app's plain-text writer fell back to an ASCII encoding.
// Prefer the rich-text variant only when it demonstrably carries more
// non-ASCII content than the plain variant, so that legitimate user
// text containing '?' runs is not replaced by a stripped RTF/HTML
// rendering.
if let s = plainText, Self.looksEncodingLossy(s),
let recovered = self.richTextFallback(),
Self.nonASCIIScalarCount(recovered) > Self.nonASCIIScalarCount(s) {

@cubic-dev-ai cubic-dev-ai Bot Apr 20, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Lossy plain text containing U+FFFD can bypass rich-text recovery because U+FFFD is counted as non-ASCII, making the strict > gate fail.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift, line 62:

<comment>Lossy plain text containing U+FFFD can bypass rich-text recovery because U+FFFD is counted as non-ASCII, making the strict `>` gate fail.</comment>

<file context>
@@ -45,7 +47,74 @@ extension NSPasteboard {
+        // rendering.
+        if let s = plainText, Self.looksEncodingLossy(s),
+           let recovered = self.richTextFallback(),
+           Self.nonASCIIScalarCount(recovered) > Self.nonASCIIScalarCount(s) {
+            return recovered
+        }
</file context>
Suggested change
Self.nonASCIIScalarCount(recovered) > Self.nonASCIIScalarCount(s) {
(Self.nonASCIIScalarCount(recovered) > Self.nonASCIIScalarCount(s) || s.contains("\u{FFFD}")) {
Fix with Cubic

return recovered
}

if let s = plainText { return s }
return self.richTextFallback()
}

private static func looksEncodingLossy(_ s: String) -> Bool {
if s.contains("\u{FFFD}") { return true }
var run = 0
for scalar in s.unicodeScalars {
if scalar == "?" {
run += 1
if run >= 3 { return true }
} else {
run = 0
}
}
return false
}
Comment on lines +70 to +82

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 3-? threshold triggers on common English punctuation

With run >= 3, text like "What???" or "Seriously???" causes looksEncodingLossy to return true, unconditionally invoking the RTF/HTML parser (which may load WebKit for HTML). The non-ASCII guard prevents a wrong swap, but the parsing overhead is paid every time a user pastes text ending in three question marks.

U+FFFD is an unambiguous encoding failure signal and never appears in correctly-produced text. Consider requiring U+FFFD as the primary trigger and raising the ?-run threshold (e.g. 5 or more) to reduce false-positive parse invocations:

private static func looksEncodingLossy(_ s: String) -> Bool {
    if s.contains("\u{FFFD}") { return true }
    var run = 0
    for scalar in s.unicodeScalars {
        if scalar == "?" {
            run += 1
            if run >= 5 { return true }
        } else {
            run = 0
        }
    }
    return false
}


private static func nonASCIIScalarCount(_ s: String) -> Int {
s.unicodeScalars.reduce(0) { $1.value > 127 ? $0 + 1 : $0 }
}
Comment on lines +84 to +86

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 \u{FFFC} skews non-ASCII count and leaks into terminal output

NSAttributedString.string replaces every embedded image/attachment in RTF or HTML with U+FFFC (OBJECT REPLACEMENT CHARACTER, value 0xFFFC). Because 0xFFFC > 127, nonASCIIScalarCount counts each \u{FFFC} as a non-ASCII scalar. This has two effects:

  1. A clipboard with mangled plain text ("?????", 0 non-ASCII) and RTF containing only a logo image (\u{FFFC}, 1 non-ASCII) satisfies 1 > 0, so richTextFallback() is returned — a single garbage character pasted instead of the original ?-run.
  2. Even in the happy path (Chinese text recovered correctly), any embedded image in the RTF/HTML causes \u{FFFC} to appear in the pasted string, which renders as visible garbage in the terminal.

Consider filtering replacement characters out of the richTextFallback result:

private func richTextFallback() -> String? {
    // ... existing extraction ...
    let cleaned = result.unicodeScalars
        .filter { $0.value != 0xFFFC && $0.value != 0xFFFD }
        .reduce(into: "") { $0.append(Character($1)) }
    return cleaned.isEmpty ? nil : cleaned
}


private func richTextFallback() -> String? {
// Try RTF first: Apple's RTF parser is local and never fetches
// external resources. Fall back to HTML only when RTF is absent,
// since HTML parsing via NSAttributedString goes through WebKit
// and is heavier even when the attributed string is only used to
// extract `.string`.
if let data = self.data(forType: .rtf),
let attr = try? NSAttributedString(
data: data,
options: [.documentType: NSAttributedString.DocumentType.rtf],
documentAttributes: nil
),
!attr.string.isEmpty {
return attr.string
}

if let data = self.data(forType: .html),

@cubic-dev-ai cubic-dev-ai Bot Apr 20, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: NSAttributedString(data:options:documentAttributes:) with .html drives WebKit internally and must run on the main thread — calling it from a background thread causes a synchronization attempt that silently times out (Apple docs: "the HTML importer should not be called from a background thread"). Add a dispatchPrecondition(condition: .onQueue(.main)) before the HTML branch to make this requirement explicit and catch future misuse.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift, line 104:

<comment>`NSAttributedString(data:options:documentAttributes:)` with `.html` drives WebKit internally and **must** run on the main thread — calling it from a background thread causes a synchronization attempt that silently times out (Apple docs: "the HTML importer should not be called from a background thread"). Add a `dispatchPrecondition(condition: .onQueue(.main))` before the HTML branch to make this requirement explicit and catch future misuse.</comment>

<file context>
@@ -45,7 +47,74 @@ extension NSPasteboard {
+            return attr.string
+        }
+
+        if let data = self.data(forType: .html),
+           let attr = try? NSAttributedString(
+               data: data,
</file context>
Fix with Cubic

let attr = try? NSAttributedString(
data: data,
options: [
.documentType: NSAttributedString.DocumentType.html,
.characterEncoding: String.Encoding.utf8.rawValue,
],
documentAttributes: nil
),
!attr.string.isEmpty {
return attr.string
}
Comment on lines +104 to +115

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find all call sites of getOpinionatedStringContents to assess threading context.
rg -nP --type=swift -C3 '\bgetOpinionatedStringContents\s*\('

Repository: manaflow-ai/ghostty

Length of output: 2940


🌐 Web query:

Does NSAttributedString init with NSAttributedString.DocumentType.html require the main thread on macOS?

💡 Result:

Yes, initializing NSAttributedString with NSAttributedString.DocumentType.html requires the main thread on macOS. Apple's official documentation explicitly states: "Don’t call this method from a background thread if the options dictionary includes the documentType attribute with a value of html. If you do, the method tries to synchronize with the main thread, fails, and times out." This applies to the synchronous initializers like init(data:options:documentAttributes:). An older Apple document confirms that since OS X v10.4, NSAttributedString uses WebKit for HTML import, which is not thread-safe on background threads; on macOS (OS X v10.5+), it transfers to the main thread via performSelectorOnMainThread:withObject:waitUntilDone:, requiring the main thread's run loop to be running. Recent forum discussions (up to 2025) affirm this behavior persists on macOS. New asynchronous APIs like loadFromHTML(data:options:completionHandler:) exist but are separate from the synchronous init methods.

Citations:


🏁 Script executed:

# Examine the call site contexts to understand threading guarantees
sed -n '2100,2130p' macos/Sources/Ghostty/Surface\ View/SurfaceView_AppKit.swift

Repository: manaflow-ai/ghostty

Length of output: 1001


🏁 Script executed:

# Check the clipboard request handler context
sed -n '330,345p' macos/Sources/Ghostty/Ghostty.App.swift

Repository: manaflow-ai/ghostty

Length of output: 738


🏁 Script executed:

# Check the C interop callback pattern to understand threading context
rg -nB5 "completeClipboardRequest" macos/Sources/Ghostty/Ghostty.App.swift | head -30

Repository: manaflow-ai/ghostty

Length of output: 587


🏁 Script executed:

# Look for the definition of the clipboard callback to understand its threading context
rg -nB10 "performable paste bindings" macos/Sources/Ghostty/Ghostty.App.swift

Repository: manaflow-ai/ghostty

Length of output: 628


🏁 Script executed:

# Search for where this callback function is registered/called to understand threading
rg -n "readClipboard\|clipboard.*callback" macos/Sources/Ghostty/Ghostty.App.swift | head -20

Repository: manaflow-ai/ghostty

Length of output: 45


🏁 Script executed:

# Check if there's a dispatch or threading guard anywhere in the clipboard handling chain
rg -nB2 "readClipboard" macos/Sources/Ghostty/Ghostty.App.swift | head -40

Repository: manaflow-ai/ghostty

Length of output: 666


🏁 Script executed:

# Check the C library interface to understand threading guarantees for clipboard callbacks
rg -n "read_clipboard_cb|ghostty_app_callbacks_s" macos/Sources/ -A3 | head -40

Repository: manaflow-ai/ghostty

Length of output: 870


🏁 Script executed:

# Look for documentation or comments about the C callback threading model
rg -n "ghostty_clipboard_e|clipboard.*thread|read.*clipboard" macos/Sources/Ghostty/ -B2 -A2 | head -50

Repository: manaflow-ai/ghostty

Length of output: 3491


🏁 Script executed:

# Search for any comments or documentation about threading in the C library headers
find . -name "*.h" -type f | head -20

Repository: manaflow-ai/ghostty

Length of output: 673


🏁 Script executed:

# Check if there are any assertions or main-thread checks elsewhere in the codebase for clipboard handling
rg -n "dispatchPrecondition\|MainActor\|Thread\|main thread" macos/Sources/Ghostty/Ghostty.App.swift | head -20

Repository: manaflow-ai/ghostty

Length of output: 45


🏁 Script executed:

# Check the main app event loop and threading model to understand if clipboard callbacks are main-thread guaranteed
head -100 macos/Sources/Ghostty/Ghostty.App.swift | grep -A20 "class App\|init\|runModal"

Repository: manaflow-ai/ghostty

Length of output: 3304


🏁 Script executed:

# Look for documentation or comments about the C library's threading model in the header
grep -n "read_clipboard_cb\|thread\|main" ./include/ghostty.h | head -30

Repository: manaflow-ai/ghostty

Length of output: 398


Add main-thread guarantee to getOpinionatedStringContents() for HTML parsing.

Apple's official documentation confirms that NSAttributedString(data:options:documentAttributes:) with DocumentType.html requires the main thread (it uses WebKit internally, which is not thread-safe). Calling this on a background thread will cause the method to attempt synchronization with the main thread, which fails and times out.

While current call sites (readSelection, pasteSelection, clipboard request handlers) appear to execute on the main thread, the method lacks defensive guards, making it fragile against future misuse.

Add either:

  • A main-thread assertion (dispatchPrecondition(condition: .onQueue(.main))) at method entry, or
  • A documented caller contract with explicit main-thread requirement in the method documentation
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@macos/Sources/Helpers/Extensions/NSPasteboard`+Extension.swift around lines
104 - 115, The HTML-to-NSAttributedString conversion inside
getOpinionatedStringContents() must only run on the main thread; add a
main-thread guard at the start of that function (e.g.,
dispatchPrecondition(condition: .onQueue(.main))) to assert caller correctness
and prevent background-thread invocation, and also update the function's doc
comment to state the main-thread requirement so callers know the contract;
ensure the guard is added before the call to
NSAttributedString(data:options:documentAttributes:) and the doc mentions
getOpinionatedStringContents() explicitly.

Comment on lines +104 to +115

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 HTML NSAttributedString init requires the main thread

NSAttributedString(data:options:documentAttributes:) with .html internally drives a WebKit document parser, which must run on the main thread. The comment in the PR description acknowledges the WebKit cost, but the code doesn't assert the calling thread. The readClipboard C callback (registered as read_clipboard_cb in Ghostty.App.swift) is invoked from the libghostty core and its threading guarantee isn't obvious from this file.

Consider adding a runtime assertion to make the requirement explicit:

if let data = self.data(forType: .html) {
    assert(Thread.isMainThread, "NSAttributedString HTML init must be called on the main thread")
    // ...
}


return nil
}

/// The pasteboard for the Ghostty enum type.
Expand Down
Loading