macos: answer ENOSYS for Kitty clipboard writes to primary

A Kitty clipboard protocol (OSC 5522) write transaction targeting
`loc=primary` replied `type=write:status=DONE` in the macOS app even
though macOS has no primary selection and the data was silently
discarded. 

The spec requires ENOSYS when the requested location is not
available on the system, which the read path already answers correctly:
https://sw.kovidgoyal.net/kitty/clipboard/
This commit is contained in:
Mitchell Hashimoto
2026-08-24 13:10:50 -07:00
parent 89d17b378e
commit 1334cc213e
4 changed files with 34 additions and 2 deletions

View File

@@ -75,6 +75,7 @@ typedef enum {
typedef enum {
GHOSTTY_CLIPBOARD_STANDARD,
GHOSTTY_CLIPBOARD_SELECTION,
GHOSTTY_CLIPBOARD_PRIMARY,
} ghostty_clipboard_e;
// One representation of clipboard contents. The data is binary-safe with

View File

@@ -132,7 +132,8 @@ extension NSPasteboard {
return result
}
/// The pasteboard for the Ghostty enum type.
/// The pasteboard for the Ghostty enum type. Returns nil for locations
/// macOS can't serve; callers report those as unsupported.
static func ghostty(_ clipboard: ghostty_clipboard_e) -> NSPasteboard? {
switch clipboard {
case GHOSTTY_CLIPBOARD_STANDARD:
@@ -141,6 +142,10 @@ extension NSPasteboard {
case GHOSTTY_CLIPBOARD_SELECTION:
return Self.ghosttySelection
case GHOSTTY_CLIPBOARD_PRIMARY:
// macOS has no primary selection.
return nil
default:
return nil
}

View File

@@ -702,7 +702,13 @@ pub const Surface = struct {
) bool {
return switch (clipboard_type) {
.standard => true,
.selection, .primary => self.app.opts.supports_selection_clipboard,
.selection => self.app.opts.supports_selection_clipboard,
// No embedder can serve the primary selection: the embedding
// API has no option to declare support for it, and macOS (the
// only GUI embedder) has no primary selection. The selection
// flag above covers a distinct custom pasteboard, not this.
.primary => false,
};
}
@@ -717,6 +723,14 @@ pub const Surface = struct {
// completion that requires confirmation diverts into the
// apprt confirmation flow just like a read.
if (state == .kitty_write) {
// A write to a location the embedder can't serve is
// rejected before the transaction completes so the caller
// answers ENOSYS without a permission prompt, matching
// reads, where the embedder reports an unsupported
// location from the read callback. The write callback only
// fires after any prompt, so it is too late to reject.
if (!self.supportsClipboard(clipboard_type)) return .unsupported;
const alloc = self.app.core_app.alloc;
const state_ptr = try alloc.create(apprt.ClipboardRequest);
errdefer alloc.destroy(state_ptr);

View File

@@ -3776,6 +3776,18 @@ test "kitty clipboard write result maps to response status" {
"\x1B]5522;type=write:status=DONE\x07",
S.responseSlice(),
);
// A system without a primary selection answers a loc=primary write
// with ENOSYS, echoing the id.
S.reset();
S.write_result = .unsupported;
s.nextSlice("\x1B]5522;type=write:loc=primary:id=p1\x1B\\");
s.nextSlice("\x1B]5522;type=wdata\x1B\\");
try testing.expectEqual(clipboard.Location.primary, S.last_location);
try testing.expectEqualStrings(
"\x1B]5522;type=write:status=ENOSYS:id=p1\x1B\\",
S.responseSlice(),
);
}
test "kitty clipboard write without clipboard effect responds ENOSYS" {