diff --git a/include/ghostty.h b/include/ghostty.h index 5523654c4..e467fa0ea 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -77,17 +77,51 @@ typedef enum { GHOSTTY_CLIPBOARD_SELECTION, } ghostty_clipboard_e; +// One representation of clipboard contents. The data is binary-safe with +// an explicit length; it is not necessarily null-terminated. typedef struct { const char *mime; const char *data; + size_t len; } ghostty_clipboard_content_s; +// The payload for completing a clipboard read request. See +// ghostty_surface_complete_clipboard_request. +typedef struct { + const ghostty_clipboard_content_s *contents; + size_t contents_len; + const char *const *available; + size_t available_len; + bool confirmed; + bool remember; +} ghostty_clipboard_complete_s; + +// The payload of a clipboard read confirmation request: the would-be +// completion contents plus the information shown in the permission +// prompt. See ghostty_runtime_confirm_read_clipboard_cb. +typedef struct { + const ghostty_clipboard_content_s *contents; + size_t contents_len; + const char *const *available; + size_t available_len; + const char *name; + bool can_remember; +} ghostty_clipboard_confirm_s; + typedef enum { GHOSTTY_CLIPBOARD_REQUEST_PASTE, GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ, GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE, + GHOSTTY_CLIPBOARD_REQUEST_KITTY_READ, } ghostty_clipboard_request_e; +// apprt.ClipboardReadResult +typedef enum { + GHOSTTY_CLIPBOARD_READ_STARTED, + GHOSTTY_CLIPBOARD_READ_UNAVAILABLE, + GHOSTTY_CLIPBOARD_READ_UNSUPPORTED, +} ghostty_clipboard_read_result_e; + typedef enum { GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_PRESS, @@ -1023,12 +1057,16 @@ typedef struct { } ghostty_action_s; typedef void (*ghostty_runtime_wakeup_cb)(void*); -typedef bool (*ghostty_runtime_read_clipboard_cb)(void*, - ghostty_clipboard_e, - void*); +typedef ghostty_clipboard_read_result_e (*ghostty_runtime_read_clipboard_cb)( + void*, + ghostty_clipboard_e, + void*, + const char* const*, + size_t, + bool); typedef void (*ghostty_runtime_confirm_read_clipboard_cb)( void*, - const char*, + const ghostty_clipboard_confirm_s*, void*, ghostty_clipboard_request_e); typedef void (*ghostty_runtime_write_clipboard_cb)(void*, @@ -1178,10 +1216,12 @@ GHOSTTY_API void ghostty_surface_split_resize(ghostty_surface_t, uint16_t); GHOSTTY_API void ghostty_surface_split_equalize(ghostty_surface_t); GHOSTTY_API bool ghostty_surface_binding_action(ghostty_surface_t, const char*, uintptr_t); -GHOSTTY_API void ghostty_surface_complete_clipboard_request(ghostty_surface_t, - const char*, - void*, - bool); +GHOSTTY_API void ghostty_surface_complete_clipboard_request( + ghostty_surface_t, + const ghostty_clipboard_complete_s*, + void*); +GHOSTTY_API void ghostty_surface_deny_clipboard_request(ghostty_surface_t, + void*); GHOSTTY_API bool ghostty_surface_has_selection(ghostty_surface_t); GHOSTTY_API bool ghostty_surface_read_selection(ghostty_surface_t, ghostty_text_s*); GHOSTTY_API bool ghostty_surface_read_text(ghostty_surface_t, diff --git a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift index 7c7b9bd3c..cfab6c9b5 100644 --- a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift +++ b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift @@ -47,13 +47,16 @@ class ClipboardConfirmationController: NSWindowController { switch confirmation.kind { case .paste: window.title = "Warning: Potentially Unsafe Paste" - case .osc_52_read, .osc_52_write: + case .osc_52_read, .osc_52_write, .kitty_read: window.title = "Authorize Clipboard Access" } window.contentView = NSHostingView(rootView: ClipboardConfirmationView( contents: confirmation.contents, request: confirmation.kind, + programName: confirmation.programName, + canRemember: confirmation.canRemember, + previewImage: confirmation.previewImage, delegate: delegate )) } diff --git a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift index c2fe5fd48..101cf5ac8 100644 --- a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift +++ b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift @@ -2,7 +2,7 @@ import SwiftUI /// This delegate is notified of the completion result of the clipboard confirmation dialog. protocol ClipboardConfirmationViewDelegate: AnyObject { - func clipboardConfirmationComplete(_ action: ClipboardConfirmationView.Action) + func clipboardConfirmationComplete(_ action: ClipboardConfirmationView.Action, remember: Bool) } /// The SwiftUI view for showing a clipboard confirmation dialog. @@ -15,11 +15,11 @@ struct ClipboardConfirmationView: View { switch (action, reason) { case (.cancel, .paste): return "Cancel" - case (.cancel, .osc_52_read), (.cancel, .osc_52_write): + case (.cancel, .osc_52_read), (.cancel, .osc_52_write), (.cancel, .kitty_read): return "Deny" case (.confirm, .paste): return "Paste" - case (.confirm, .osc_52_read), (.confirm, .osc_52_write): + case (.confirm, .osc_52_read), (.confirm, .osc_52_write), (.confirm, .kitty_read): return "Allow" } } @@ -31,9 +31,24 @@ struct ClipboardConfirmationView: View { /// The type of the clipboard request let request: Ghostty.ClipboardRequest + /// The human friendly name of the requesting program, when the + /// protocol carries one. + var programName: String? + + /// True when the user's decision may be remembered as a session + /// grant, showing the remember toggle. + var canRemember: Bool = false + + /// An image decoded from the request contents, shown scaled in + /// place of most of the text area when present. + var previewImage: NSImage? + /// Optional delegate to get results. If this is nil, then this view will never close on its own. weak var delegate: ClipboardConfirmationViewDelegate? + /// Whether the user's decision should be remembered for the session. + @State private var remember: Bool = false + /// Used to track if we should rehide on disappear @State private var cursorHiddenCount: UInt = 0 @@ -46,14 +61,27 @@ struct ClipboardConfirmationView: View { .padding() .frame(alignment: .center) - Text(request.text()) + Text(request.text(name: programName)) .frame(maxWidth: .infinity, alignment: .leading) .padding() } - TextEditor(text: .constant(contents)) - .focusable(false) - .font(.system(.body, design: .monospaced)) + if let previewImage { + Image(nsImage: previewImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal) + } else { + TextEditor(text: .constant(contents)) + .focusable(false) + .font(.system(.body, design: .monospaced)) + } + + if canRemember { + Toggle("Remember this choice for the session", isOn: $remember) + .padding(.top, 4) + } HStack { Spacer() @@ -87,10 +115,10 @@ struct ClipboardConfirmationView: View { } private func onCancel() { - delegate?.clipboardConfirmationComplete(.cancel) + delegate?.clipboardConfirmationComplete(.cancel, remember: false) } private func onPaste() { - delegate?.clipboardConfirmationComplete(.confirm) + delegate?.clipboardConfirmationComplete(.confirm, remember: remember) } } diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 79488106e..b563266d9 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -1665,7 +1665,7 @@ extension BaseTerminalController { target.pendingClipboardConfirmation = nil } - func clipboardConfirmationComplete(_ action: ClipboardConfirmationView.Action) { + func clipboardConfirmationComplete(_ action: ClipboardConfirmationView.Action, remember: Bool) { // End our clipboard confirmation no matter what guard let cc = self.clipboardConfirmation else { return } dismissClipboardConfirmation(cc) @@ -1674,7 +1674,7 @@ extension BaseTerminalController { case .cancel: cc.confirmation.cancel() case .confirm: - cc.confirmation.complete() + cc.confirmation.complete(remember: remember) } // Clear only if this is still the surface's current request. Completing diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 943120413..bf06e3ef7 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -60,8 +60,20 @@ extension Ghostty { supports_selection_clipboard: true, wakeup_cb: { userdata in App.wakeup(userdata) }, action_cb: { app, target, action in App.action(app!, target: target, action: action) }, - read_clipboard_cb: { userdata, loc, state in App.readClipboard(userdata, location: loc, state: state) }, - confirm_read_clipboard_cb: { userdata, str, state, request in App.confirmReadClipboard(userdata, string: str, state: state, request: request ) }, + read_clipboard_cb: { userdata, loc, state, mimes, mimesLen, list in + App.readClipboard( + userdata, + location: loc, + state: state, + mimes: mimes, + mimesLen: mimesLen, + list: list) }, + confirm_read_clipboard_cb: { userdata, confirm, state, request in + App.confirmReadClipboard( + userdata, + confirm: confirm, + state: state, + request: request) }, write_clipboard_cb: { userdata, loc, content, len, confirm in App.writeClipboard(userdata, location: loc, content: content, len: len, confirm: confirm) }, close_surface_cb: { userdata, processAlive in App.closeSurface(userdata, processAlive: processAlive) } @@ -286,60 +298,186 @@ extension Ghostty { static func readClipboard( _ userdata: UnsafeMutableRawPointer?, location: ghostty_clipboard_e, - state: UnsafeMutableRawPointer? - ) -> Bool { + state: UnsafeMutableRawPointer?, + mimes: UnsafePointer?>?, + mimesLen: Int, + list: Bool + ) -> ghostty_clipboard_read_result_e { let surfaceView = self.surfaceUserdata(from: userdata) - guard let surface = surfaceView.surface else { return false } + guard let surface = surfaceView.surface else { + return GHOSTTY_CLIPBOARD_READ_UNSUPPORTED + } // Get our pasteboard - guard let pasteboard = NSPasteboard.ghostty(location) else { return false } + guard let pasteboard = NSPasteboard.ghostty(location) else { + return GHOSTTY_CLIPBOARD_READ_UNSUPPORTED + } - // Return false if there is no text-like clipboard content so - // performable paste bindings can pass through to the terminal. - guard let str = pasteboard.getOpinionatedStringContents() else { return false } + // Gather the representation for each requested MIME type that + // the pasteboard can serve. We only ever read the requested + // representations so unrelated (potentially large) clipboard + // contents are never loaded. + var contents: [Ghostty.ClipboardContent] = [] + var seen = Set() + if let mimes { + for i in 0..?, + confirm: UnsafePointer?, state: UnsafeMutableRawPointer?, request: ghostty_clipboard_request_e ) { let surfaceView = self.surfaceUserdata(from: userdata) - guard surfaceView.surface != nil, - let string, - let valueStr = String(cString: string, encoding: .utf8), - let kind = Ghostty.ClipboardRequest.from(request: request) else { return } + guard let surface = surfaceView.surface else { return } + guard let confirm, + let kind = Ghostty.ClipboardRequest.from(request: request) else { + ghostty_surface_deny_clipboard_request(surface, state) + return + } + let c = confirm.pointee + + // Copy the borrowed C representations: the confirmation is + // asynchronous and completes with exactly what the user + // approved, so the clipboard is never re-read. + var reps: [Ghostty.ClipboardContent] = [] + if let contents = c.contents { + for i in 0.. 0 { + Data(bytes: content.data, count: content.len) + } else { + Data() + } + reps.append(.init(mime: String(cString: content.mime), data: data)) + } + } + var avail: [String] = [] + if let available = c.available { + for i in 0..] = [] + var cDatas: [UnsafeMutableRawPointer] = [] + defer { + cStrings.forEach { free($0) } + cDatas.forEach { $0.deallocate() } + } + + var cContents: [ghostty_clipboard_content_s] = [] + for entry in contents { + guard let mime = strdup(entry.mime) else { continue } + cStrings.append(mime) + let buf = UnsafeMutableRawPointer.allocate( + byteCount: max(entry.data.count, 1), + alignment: 1) + cDatas.append(buf) + entry.data.withUnsafeBytes { src in + if let base = src.baseAddress { + buf.copyMemory(from: base, byteCount: src.count) + } + } + cContents.append(ghostty_clipboard_content_s( + mime: mime, + data: buf.assumingMemoryBound(to: CChar.self), + len: entry.data.count)) + } + + var cAvailable: [UnsafePointer?] = [] + for mime in available { + guard let str = strdup(mime) else { continue } + cStrings.append(str) + cAvailable.append(UnsafePointer(str)) + } + + cContents.withUnsafeBufferPointer { contentsBuf in + cAvailable.withUnsafeBufferPointer { availableBuf in + var complete = ghostty_clipboard_complete_s( + contents: contentsBuf.baseAddress, + contents_len: contentsBuf.count, + available: availableBuf.baseAddress, + available_len: availableBuf.count, + confirmed: confirmed, + remember: remember) + ghostty_surface_complete_clipboard_request(surface, &complete, state) + } } } @@ -376,24 +514,25 @@ extension Ghostty { // Set data for each type for item in contentArray { guard let type = NSPasteboard.PasteboardType(mimeType: item.mime) else { continue } - pasteboard.setString(item.data, forType: type) + pasteboard.setData(item.data, forType: type) } return } // For confirmation, use the text/plain content if it exists - guard let textPlainContent = contentArray.first(where: { $0.mime == "text/plain" }) else { + guard let textPlainContent = contentArray.first(where: { $0.mime == "text/plain" }), + let textPlainString = textPlainContent.string else { return } let request = Ghostty.ClipboardConfirmationRequest( surface: surfaceView, - contents: textPlainContent.data, + contents: textPlainString, kind: .osc_52_write - ) { _, contents in - guard let contents else { return } + ) { _, confirmed, _ in + guard confirmed else { return } pasteboard.declareTypes([.string], owner: nil) - pasteboard.setString(contents, forType: .string) + pasteboard.setString(textPlainString, forType: .string) } surfaceView.pendingClipboardConfirmation = request } diff --git a/macos/Sources/Ghostty/Ghostty.ClipboardConfirmationRequest.swift b/macos/Sources/Ghostty/Ghostty.ClipboardConfirmationRequest.swift index 3049366f0..ee31b179f 100644 --- a/macos/Sources/Ghostty/Ghostty.ClipboardConfirmationRequest.swift +++ b/macos/Sources/Ghostty/Ghostty.ClipboardConfirmationRequest.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation import GhosttyKit @@ -13,21 +14,28 @@ extension Ghostty { /// An application is attempting to write to the clipboard using OSC 52. case osc_52_write - /// The text to show in the clipboard confirmation prompt for this request. - func text() -> String { + /// An application is attempting to read from the clipboard using + /// the Kitty clipboard protocol (OSC 5522). + case kitty_read + + /// The text to show in the clipboard confirmation prompt for this + /// request. The name is the requesting program's human friendly + /// name, when the protocol carries one. + func text(name: String? = nil) -> String { + let program = name.map { "\"\($0)\"" } ?? "An application" switch self { case .paste: return """ Pasting this text to the terminal may be dangerous as it looks like some commands may be executed. """ - case .osc_52_read: + case .osc_52_read, .kitty_read: return """ - An application is attempting to read from the clipboard. + \(program) is attempting to read from the clipboard. The current clipboard contents are shown below. """ case .osc_52_write: return """ - An application is attempting to write to the clipboard. + \(program) is attempting to write to the clipboard. The content to write is shown below. """ } @@ -41,6 +49,8 @@ extension Ghostty { return .osc_52_read case GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE: return .osc_52_write + case GHOSTTY_CLIPBOARD_REQUEST_KITTY_READ: + return .kitty_read default: return nil } @@ -56,20 +66,46 @@ extension Ghostty { /// occurs from inside the libghostty callback that created the request. final class ClipboardConfirmationRequest { private(set) weak var surface: SurfaceView? + + /// The textual preview of the clipboard contents shown in the + /// confirmation dialog. The actual representations served on + /// confirmation are held by the completion. let contents: String + let kind: ClipboardRequest - private var completion: ((SurfaceView, String?) -> Void)? + /// The human friendly name of the requesting program to show in + /// the prompt, when the protocol carries one. + let programName: String? + + /// True when the user's decision may be remembered as a session + /// grant, showing a remember option in the prompt. + let canRemember: Bool + + /// An image decoded from the request contents, previewed scaled + /// in the dialog when the request carries an image + /// representation. + let previewImage: NSImage? + + /// Called exactly once with whether the user confirmed the + /// request and whether their decision should be remembered. + private var completion: ((SurfaceView, Bool, Bool) -> Void)? init( surface: SurfaceView, contents: String, kind: ClipboardRequest, - completion: @escaping (SurfaceView, String?) -> Void + programName: String? = nil, + canRemember: Bool = false, + previewImage: NSImage? = nil, + completion: @escaping (SurfaceView, Bool, Bool) -> Void ) { self.surface = surface self.contents = contents self.kind = kind + self.programName = programName + self.canRemember = canRemember + self.previewImage = previewImage self.completion = completion } @@ -77,29 +113,30 @@ extension Ghostty { guard let surface, let completion else { return } self.completion = nil DispatchQueue.main.async { - completion(surface, nil) + completion(surface, false, false) } } - /// Complete the request using the displayed clipboard contents. - func complete() { - finish(contents) + /// Complete the request with the displayed clipboard contents. + func complete(remember: Bool = false) { + finish(true, remember: remember) } - /// Cancel the request without using the displayed clipboard contents. + /// Cancel the request, denying access to the clipboard contents. func cancel() { - finish(nil) + finish(false) } /// Cancel using the owning surface explicitly. SurfaceView uses this /// for replacement and teardown because its weak reference is already /// nil during the owner's deinitialization. func cancel(from surface: SurfaceView) { - finish(nil, on: surface) + finish(false, on: surface) } private func finish( - _ contents: String?, + _ confirmed: Bool, + remember: Bool = false, on explicitSurface: SurfaceView? = nil ) { guard let surface = explicitSurface ?? self.surface, @@ -108,7 +145,7 @@ extension Ghostty { return } self.completion = nil - completion(surface, contents) + completion(surface, confirmed, remember) } } } diff --git a/macos/Sources/Ghostty/GhosttyPackage.swift b/macos/Sources/Ghostty/GhosttyPackage.swift index 313223aa3..0cbdf03f4 100644 --- a/macos/Sources/Ghostty/GhosttyPackage.swift +++ b/macos/Sources/Ghostty/GhosttyPackage.swift @@ -237,9 +237,14 @@ extension Ghostty.SplitFocusDirection { } extension Ghostty { + /// One representation of clipboard contents. The data is binary-safe; + /// textual consumers use `string`. struct ClipboardContent { let mime: String - let data: String + let data: Data + + /// The data as text, if it is valid UTF-8. + var string: String? { String(data: data, encoding: .utf8) } static func from(content: ghostty_clipboard_content_s) -> ClipboardContent? { guard let mimePtr = content.mime, @@ -247,9 +252,15 @@ extension Ghostty { return nil } + let data: Data = if content.len > 0 { + Data(bytes: dataPtr, count: content.len) + } else { + Data() + } + return ClipboardContent( mime: String(cString: mimePtr), - data: String(cString: dataPtr) + data: data ) } } diff --git a/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift b/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift index 9dbed4614..fc13c807a 100644 --- a/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSPasteboard+Extension.swift @@ -54,6 +54,74 @@ extension NSPasteboard { return strings.joined(separator: " ") } + /// The file URLs on the pasteboard, e.g. files copied in Finder. + private var ghosttyFileURLs: [URL] { + (pasteboardItems ?? []).compactMap { item in + guard let plist = item.propertyList(forType: .fileURL), + let url = NSURL(pasteboardPropertyList: plist, ofType: .fileURL) as URL?, + url.isFileURL else { return nil } + return url + } + } + + /// The data for the given MIME type, if the pasteboard can serve it. + /// + /// The canonical "text/plain" type uses the opinionated string + /// contents so that e.g. copying a file yields its escaped path; + /// this matches what pasting into the terminal produces. Copied + /// files are additionally served as "text/uri-list" (RFC 2483, the + /// type X11/Wayland clipboards carry file copies under). All other + /// types are mapped through UTType. + func ghosttyData(forMime mime: String) -> Data? { + switch mime { + case "text/plain": + guard let str = getOpinionatedStringContents() else { return nil } + return Data(str.utf8) + + case "text/uri-list": + let urls = ghosttyFileURLs + guard !urls.isEmpty else { return nil } + return Data(urls.map { $0.absoluteString + "\r\n" }.joined().utf8) + + default: + guard let type = NSPasteboard.PasteboardType(mimeType: mime) else { return nil } + return data(forType: type) + } + } + + /// The MIME types available on the pasteboard, best-effort mapped + /// from the pasteboard types. Types without a MIME mapping are not + /// reported. + func ghosttyAvailableMimes() -> [String] { + var result: [String] = [] + var seen = Set() + + // Any text-like contents are reported under the canonical type, + // matching what ghosttyData(forMime:) serves. + if getOpinionatedStringContents() != nil { + result.append("text/plain") + seen.insert("text/plain") + } + + // Copied files are additionally served as a URI list. The + // generic mapping below never reports this since file URL + // pasteboard types have no MIME type. + if !ghosttyFileURLs.isEmpty { + result.append("text/uri-list") + seen.insert("text/uri-list") + } + + for type in types ?? [] { + guard let utType = UTType(type.rawValue), + let mime = utType.preferredMIMEType, + !seen.contains(mime) else { continue } + seen.insert(mime) + result.append(mime) + } + + return result + } + /// The pasteboard for the Ghostty enum type. static func ghostty(_ clipboard: ghostty_clipboard_e) -> NSPasteboard? { switch clipboard { diff --git a/src/Surface.zig b/src/Surface.zig index c57eeab54..626d514d5 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1061,6 +1061,8 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { _ = try self.startClipboardRequest(.standard, .{ .osc_52_read = clipboard }); }, + .kitty_clipboard_read => |req| try self.kittyClipboardRead(req), + .clipboard_write => |w| switch (w.req) { .small => |v| try self.clipboardWrite(v.data[0..v.len], w.clipboard_type), .stable => |v| try self.clipboardWrite(v, w.clipboard_type), @@ -5107,15 +5109,15 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool {}, ), - .paste_from_clipboard => return try self.startClipboardRequest( + .paste_from_clipboard => return (try self.startClipboardRequest( .standard, .{ .paste = {} }, - ), + )) == .started, - .paste_from_selection => return try self.startClipboardRequest( + .paste_from_selection => return (try self.startClipboardRequest( .selection, .{ .paste = {} }, - ), + )) == .started, .increase_font_size => |delta| { // Max delta is somewhat arbitrary. @@ -5845,54 +5847,165 @@ fn writeScreenFile( retain_tmp_dir = true; } +/// The payload for completing a clipboard request with +/// completeClipboardRequest. +pub const CompleteClipboard = struct { + /// The representations the apprt could serve for the request's MIME + /// types. These are immediately copied as needed so they only need + /// to live for the duration of the completion call. Requesters that + /// only carry text (paste, OSC 52) use the first text-like + /// representation. + contents: []const terminal.clipboard.Content = &.{}, + + /// The listing of MIME types available on the clipboard, only + /// gathered when the request asked for it. + available: []const []const u8 = &.{}, + + /// True if any clipboard confirmation prompt was already answered + /// by the user, skipping further prompts: + /// + /// - For "regular" pasting this means that unsafe pastes are + /// allowed. Unsafe data is defined as data that contains + /// newlines, though this definition may change later to detect + /// other scenarios. + /// + /// - For OSC 52 and Kitty clipboard protocol reads and writes no + /// prompt is shown to the user when this is true. + confirmed: bool = false, + + /// True if the user asked to remember their decision. This is only + /// honored by request types that support session grants (Kitty + /// clipboard protocol requests carrying a password). + remember: bool = false, +}; + /// Call this to complete a clipboard request sent to apprt. This should -/// only be called once for each request. The data is immediately copied so -/// it is safe to free the data after this call. +/// only be called once for each request. /// -/// If `confirmed` is true then any clipboard confirmation prompts are skipped: -/// -/// - For "regular" pasting this means that unsafe pastes are allowed. Unsafe -/// data is defined as data that contains newlines, though this definition -/// may change later to detect other scenarios. -/// -/// - For OSC 52 reads and writes no prompt is shown to the user if -/// `confirmed` is true. -/// -/// If `confirmed` is false then this may return either an UnsafePaste or -/// UnauthorizedPaste error, depending on the type of clipboard request. +/// If `complete.confirmed` is false then this may return either an +/// UnsafePaste or UnauthorizedPaste error, depending on the type of +/// clipboard request. The request state remains alive in that case so +/// the apprt can run its confirmation flow. pub fn completeClipboardRequest( self: *Surface, req: apprt.ClipboardRequest, - data: [:0]const u8, - confirmed: bool, + complete: CompleteClipboard, ) !void { switch (req) { - .paste => try self.completeClipboardPaste(data, confirmed), - - .osc_52_read => |clipboard| try self.completeClipboardReadOSC52( - data, - clipboard, - confirmed, + .paste => try self.completeClipboardPaste( + clipboardTextContent(complete.contents) orelse "", + complete.confirmed, ), - .osc_52_write => |clipboard| try self.rt_surface.setClipboard(clipboard, &.{.{ - .mime = "text/plain", - .data = data, - }}, !confirmed), + .osc_52_read => |clipboard| try self.completeClipboardReadOSC52( + clipboardTextContent(complete.contents) orelse "", + clipboard, + complete.confirmed, + ), + + .osc_52_write => |clipboard| { + // The write API wants sentinel-terminated data; the write + // text round-tripped through the apprt confirmation flow as + // a plain representation. + const data = try self.alloc.dupeZ( + u8, + clipboardTextContent(complete.contents) orelse "", + ); + defer self.alloc.free(data); + try self.rt_surface.setClipboard(clipboard, &.{.{ + .mime = "text/plain", + .data = data, + }}, !complete.confirmed); + }, + + .kitty_read => |kitty| { + // If we need confirmation we return an error without + // consuming the request state; the apprt keeps it alive + // for the confirmation flow. A session grant carried by + // the request skips the prompt. + if (self.config.clipboard_read == .ask and + !complete.confirmed and + !kitty.granted) + { + return error.UnauthorizedPaste; + } + + // Past the confirmation check the request is consumed: + // every path from here, including errors, must destroy it. + defer kitty.destroy(); + + // Record a session grant when the user asked to remember + // their decision and the request carried a usable + // password. The grants live with the terminal state on + // the IO thread. + if (complete.remember and kitty.pw.len > 0) { + const pw = try self.alloc.dupe(u8, kitty.pw); + self.queueIo(.{ .kitty_clipboard_grant = .{ + .alloc = self.alloc, + .pw = pw, + } }, .unlocked); + } + + try self.completeKittyClipboardRead( + kitty, + complete.contents, + complete.available, + ); + }, + } +} + +/// The first text-like representation of the contents, if any. +fn clipboardTextContent(contents: []const terminal.clipboard.Content) ?[]const u8 { + for (contents) |content| { + if (terminal.clipboard.isTextMime(content.mime)) return content.data; + } + return null; +} + +/// Deny an in-flight clipboard request. This consumes the request: for +/// request types whose protocol expects an answer, the denial reply is +/// written to the pty. +pub fn denyClipboardRequest(self: *Surface, req: apprt.ClipboardRequest) void { + switch (req) { + // A denied paste simply doesn't happen. + .paste => {}, + + // OSC 52 has no error responses, but the client is waiting on + // a reply, so a denied read is answered with empty contents. + .osc_52_read => |clipboard| self.completeClipboardReadOSC52( + "", + clipboard, + true, + ) catch |err| { + log.warn("error replying to OSC 52 clipboard read err={}", .{err}); + }, + + // A denied write simply doesn't happen. + .osc_52_write => {}, + + // The Kitty clipboard protocol reports denial explicitly. + .kitty_read => |kitty| { + defer kitty.destroy(); + self.kittyClipboardReadStatus(kitty, .EPERM) catch |err| { + log.warn("error replying to kitty clipboard read err={}", .{err}); + }; + }, } } /// This starts a clipboard request, with some basic validation. For example, /// an OSC 52 request is not actually requested if OSC 52 is disabled. /// -/// Returns true if the request was started, false if it was not (e.g., clipboard -/// doesn't contain text for paste requests). This allows performable keybinds -/// to pass through when the action cannot be performed. +/// The result reports whether the request was started; requests that +/// weren't started never complete. Callers own reacting to that, e.g. +/// performable paste keybinds pass through and Kitty clipboard reads +/// answer the program. fn startClipboardRequest( self: *Surface, loc: apprt.Clipboard, req: apprt.ClipboardRequest, -) !bool { +) !apprt.ClipboardReadResult { switch (req) { .paste => {}, // always allowed .osc_52_read => if (self.config.clipboard_read == .deny) { @@ -5900,9 +6013,13 @@ fn startClipboardRequest( "application attempted to read clipboard, but 'clipboard-read' is set to deny", .{}, ); - return false; + return .unsupported; }, + // The clipboard-read policy was already applied by + // kittyClipboardRead, which owns replying on denial. + .kitty_read => {}, + // No clipboard write code paths travel through this function .osc_52_write => unreachable, } @@ -6034,6 +6151,129 @@ fn completeClipboardReadOSC52( } }, .unlocked); } +/// Handle a Kitty clipboard protocol (OSC 5522) read request forwarded +/// by the IO thread. This takes ownership of the request state. +fn kittyClipboardRead( + self: *Surface, + req: *apprt.ClipboardRequest.KittyRead, +) !void { + // A read denied by policy answers EPERM so clients degrade + // gracefully instead of waiting on a response that never comes. + if (self.config.clipboard_read == .deny) { + defer req.destroy(); + log.info("application attempted to read clipboard, but 'clipboard-read' is set to deny", .{}); + try self.kittyClipboardReadStatus(req, .EPERM); + return; + } + + const result = self.startClipboardRequest( + req.location, + .{ .kitty_read = req }, + ) catch |err| { + defer req.destroy(); + self.kittyClipboardReadStatus(req, .EIO) catch {}; + return err; + }; + + switch (result) { + // The request completes asynchronously. + .started => {}, + + // The clipboard has nothing we can serve, which is a + // successful read that serves no representations. This never + // prompts even under an ask policy since there are no contents + // to disclose. + .unavailable => { + defer req.destroy(); + try self.completeKittyClipboardRead(req, &.{}, &.{}); + }, + + // The apprt can't serve this clipboard at all, e.g. an + // unsupported primary selection. + .unsupported => { + defer req.destroy(); + try self.kittyClipboardReadStatus(req, .ENOSYS); + }, + } +} + +/// Reply to a Kitty clipboard read with a single status packet. +fn kittyClipboardReadStatus( + self: *Surface, + req: *const apprt.ClipboardRequest.KittyRead, + status: terminal.kitty.clipboard.Status, +) !void { + var aw: std.Io.Writer.Allocating = .init(self.alloc); + defer aw.deinit(); + try (terminal.kitty.clipboard.Response{ + .op = .read, + .status = status, + .id = req.id, + .terminator = req.terminator, + }).encode(&aw.writer); + + self.queueIo(.{ .write_alloc = .{ + .alloc = self.alloc, + .data = try aw.toOwnedSlice(), + } }, .unlocked); +} + +/// Complete a Kitty clipboard protocol read with the clipboard +/// contents. +fn completeKittyClipboardRead( + self: *Surface, + req: *const apprt.ClipboardRequest.KittyRead, + contents: []const terminal.clipboard.Content, + available: []const []const u8, +) !void { + const kitty_clipboard = terminal.kitty.clipboard; + + // Serve the requested representations in request order under their + // requested names. Text-like MIME aliases all match the canonical + // text representation, since that is the only name the apprt + // serves text under. Requested types without a representation are + // simply never served, which is how the protocol communicates an + // unavailable representation. + var contents_buf: [kitty_clipboard.max_read_mimes]terminal.clipboard.Content = undefined; + var contents_len: usize = 0; + for (req.mimes) |mime| { + const data: []const u8 = data: { + for (contents) |content| { + if (std.mem.eql(u8, content.mime, mime)) break :data content.data; + if (terminal.clipboard.isTextMime(mime) and + terminal.clipboard.isTextMime(content.mime)) + { + break :data content.data; + } + } + + continue; + }; + + contents_buf[contents_len] = .{ .mime = mime, .data = data }; + contents_len += 1; + } + + // Encode the full success sequence: the OK packet, the targets + // listing if it was requested, DATA chunks for each served + // representation, and the final DONE packet. + var aw: std.Io.Writer.Allocating = .init(self.alloc); + defer aw.deinit(); + try (kitty_clipboard.ReadSuccess{ + .primary = req.location == .primary, + .id = req.id, + .list = req.list, + .available = available, + .contents = contents_buf[0..contents_len], + .terminator = req.terminator, + }).encode(&aw.writer); + + self.queueIo(.{ .write_alloc = .{ + .alloc = self.alloc, + .data = try aw.toOwnedSlice(), + } }, .unlocked); +} + fn showDesktopNotification(self: *Surface, title: [:0]const u8, body: [:0]const u8) !void { // Wyhash is used to hash the contents of the desktop notification to limit // how fast identical notifications can be sent sequentially. diff --git a/src/apprt.zig b/src/apprt.zig index c467f1801..ef788c9bb 100644 --- a/src/apprt.zig +++ b/src/apprt.zig @@ -27,6 +27,7 @@ pub const Target = action.Target; pub const ContentScale = structs.ContentScale; pub const Clipboard = structs.Clipboard; pub const ClipboardContent = structs.ClipboardContent; +pub const ClipboardReadResult = structs.ClipboardReadResult; pub const ClipboardRequest = structs.ClipboardRequest; pub const ClipboardRequestType = structs.ClipboardRequestType; pub const ColorScheme = structs.ColorScheme; diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index a380dc50c..16c3d2cc1 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -52,18 +52,36 @@ pub const App = struct { /// Callback called to handle an action. action: *const fn (*App, apprt.Target.C, apprt.Action.C) callconv(.c) bool, - /// Read the clipboard value. Returns true if the clipboard request - /// was started and complete_clipboard_request may be called with the - /// given state pointer. Returns false if the clipboard request couldn't - /// be started (such as when no text is available for a paste request). - read_clipboard: *const fn (SurfaceUD, c_int, *apprt.ClipboardRequest) callconv(.c) bool, + /// Read the clipboard value. The result only reports facts about + /// the clipboard: whether the request was started (in which case + /// complete_clipboard_request or deny_clipboard_request must + /// eventually be called with the given state pointer), whether the + /// clipboard has no servable contents, or whether the clipboard + /// can't be read at all. How each non-started state is answered is + /// up to the core. + /// + /// The MIME types are exactly the representations the caller wants + /// served; the embedder should read only those. Text-like types + /// are always requested as the canonical "text/plain". The final + /// bool asks for the listing of all MIME types available on the + /// clipboard to be delivered with the completion. + read_clipboard: *const fn ( + SurfaceUD, + c_int, + *apprt.ClipboardRequest, + [*]const [*:0]const u8, + usize, + bool, + ) callconv(.c) apprt.ClipboardReadResult, /// This may be called after a read clipboard call to request - /// confirmation that the clipboard value is safe to read. The embedder - /// must call complete_clipboard_request with the given request. + /// confirmation that the clipboard value is safe to read. The + /// embedder must call complete_clipboard_request (usually with + /// the confirmation's contents, which are only borrowed for + /// this call) or deny_clipboard_request with the given request. confirm_read_clipboard: *const fn ( SurfaceUD, - [*:0]const u8, + *const CAPI.ClipboardConfirm, *apprt.ClipboardRequest, apprt.ClipboardRequestType, ) callconv(.c) void, @@ -692,7 +710,35 @@ pub const Surface = struct { self: *Surface, clipboard_type: apprt.Clipboard, state: apprt.ClipboardRequest, - ) !bool { + ) !apprt.ClipboardReadResult { + // The representations the read wants served. Text-only + // requesters ask for the canonical text type; Kitty clipboard + // reads ask for exactly what the program requested, with + // text-like aliases normalized so the embedder never has to + // know about them. + var mimes_buf: [terminal.kitty.clipboard.max_read_mimes][*:0]const u8 = undefined; + const mimes: []const [*:0]const u8 = switch (state) { + .paste, .osc_52_read => &.{"text/plain"}, + + .kitty_read => |kitty| mimes: { + assert(kitty.mimes.len <= mimes_buf.len); + for (kitty.mimes, mimes_buf[0..kitty.mimes.len]) |mime, *dst| { + dst.* = if (terminal.clipboard.isTextMime(mime)) + "text/plain" + else + mime.ptr; + } + break :mimes mimes_buf[0..kitty.mimes.len]; + }, + + // No clipboard write code paths travel through this function + .osc_52_write => unreachable, + }; + const list = switch (state) { + .kitty_read => |kitty| kitty.list, + else => false, + }; + // We need to allocate to get a pointer to store our clipboard request // so that it is stable until the read_clipboard callback and call // complete_clipboard_request. This sucks but clipboard requests aren't @@ -702,40 +748,94 @@ pub const Surface = struct { errdefer alloc.destroy(state_ptr); state_ptr.* = state; - const started = self.app.opts.read_clipboard( + const result = self.app.opts.read_clipboard( self.userdata, @intCast(@intFromEnum(clipboard_type)), state_ptr, + mimes.ptr, + mimes.len, + list, ); - if (!started) { - alloc.destroy(state_ptr); - return false; - } - return true; + // Only a started request completes later and keeps the state. + if (result != .started) alloc.destroy(state_ptr); + return result; } fn completeClipboardRequest( self: *Surface, - str: [:0]const u8, + complete: *const CAPI.ClipboardComplete, state: *apprt.ClipboardRequest, - confirmed: bool, ) void { const alloc = self.app.core_app.alloc; + // Convert the C representations to the core types. Everything + // remains borrowed from the caller for the duration of the call. + var stack = std.heap.stackFallback(1024, alloc); + const conv_alloc = stack.get(); + + const raw_contents: []const CAPI.ClipboardContent = + if (complete.contents) |v| v[0..complete.contents_len] else &.{}; + const contents = conv_alloc.alloc( + terminal.clipboard.Content, + raw_contents.len, + ) catch |err| { + log.err("error completing clipboard request err={}", .{err}); + alloc.destroy(state); + return; + }; + defer conv_alloc.free(contents); + for (raw_contents, contents) |raw, *content| content.* = .{ + .mime = std.mem.sliceTo(raw.mime, 0), + .data = raw.data[0..raw.len], + }; + + const raw_available: []const [*:0]const u8 = + if (complete.available) |v| v[0..complete.available_len] else &.{}; + const available = conv_alloc.alloc( + []const u8, + raw_available.len, + ) catch |err| { + log.err("error completing clipboard request err={}", .{err}); + alloc.destroy(state); + return; + }; + defer conv_alloc.free(available); + for (raw_available, available) |raw, *mime| { + mime.* = std.mem.sliceTo(raw, 0); + } + // Attempt to complete the request, but we may request // confirmation. - self.core_surface.completeClipboardRequest( - state.*, - str, - confirmed, - ) catch |err| switch (err) { + self.core_surface.completeClipboardRequest(state.*, .{ + .contents = contents, + .available = available, + .confirmed = complete.confirmed, + .remember = complete.remember, + }) catch |err| switch (err) { error.UnsafePaste, error.UnauthorizedPaste, => { + // Session grant information for the permission prompt, + // carried only by Kitty clipboard protocol requests. + const name: ?[*:0]const u8, const can_remember: bool = switch (state.*) { + .kitty_read => |kitty| .{ + if (kitty.name.len > 0) kitty.name.ptr else null, + kitty.pw.len > 0, + }, + else => .{ null, false }, + }; + self.app.opts.confirm_read_clipboard( self.userdata, - str.ptr, + &.{ + .contents = complete.contents, + .contents_len = complete.contents_len, + .available = complete.available, + .available_len = complete.available_len, + .name = name, + .can_remember = can_remember, + }, state, state.*, ); @@ -751,6 +851,14 @@ pub const Surface = struct { alloc.destroy(state); } + fn denyClipboardRequest( + self: *Surface, + state: *apprt.ClipboardRequest, + ) void { + self.core_surface.denyClipboardRequest(state.*); + self.app.core_app.alloc.destroy(state); + } + pub fn setClipboard( self: *const Surface, clipboard_type: apprt.Clipboard, @@ -763,7 +871,8 @@ pub const Surface = struct { for (contents, 0..) |content, i| { array[i] = .{ .mime = content.mime, - .data = content.data, + .data = content.data.ptr, + .len = content.data.len, }; } @@ -1301,9 +1410,48 @@ pub const CAPI = struct { }; // ghostty_clipboard_content_s + // + // One representation of clipboard contents. The data is binary-safe + // and its length is explicit; it is not sentinel-terminated. const ClipboardContent = extern struct { mime: [*:0]const u8, - data: [*:0]const u8, + data: [*]const u8, + len: usize, + }; + + // ghostty_clipboard_complete_s + // + // The payload for completing a clipboard read request. See + // Surface.CompleteClipboard for the field documentation. + const ClipboardComplete = extern struct { + contents: ?[*]const ClipboardContent, + contents_len: usize, + available: ?[*]const [*:0]const u8, + available_len: usize, + confirmed: bool, + remember: bool, + }; + + // ghostty_clipboard_confirm_s + // + // The payload of a clipboard read confirmation request: the + // would-be completion contents plus the information shown in the + // permission prompt. All memory is borrowed for the duration of + // the confirm_read_clipboard callback. + const ClipboardConfirm = extern struct { + contents: ?[*]const ClipboardContent, + contents_len: usize, + available: ?[*]const [*:0]const u8, + available_len: usize, + + /// The human friendly name of the requesting program for the + /// prompt, null when the protocol doesn't carry one. + name: ?[*:0]const u8, + + /// True when the user's decision may be remembered as a + /// session grant, reported back through the completion's + /// remember field. + can_remember: bool, }; // ghostty_text_s @@ -1995,20 +2143,32 @@ pub const CAPI = struct { }; } - /// Complete a clipboard read request started via the read callback. - /// This can only be called once for a given request. Once it is called - /// with a request the request pointer will be invalidated. + /// Complete a clipboard read request started via the read callback + /// with the representations that could be served and, if requested, + /// the listing of available MIME types. All memory is borrowed for + /// the duration of the call. This can only be called once for a given + /// request. Once it is called with a request the request pointer will + /// be invalidated. + /// + /// To deny a request use ghostty_surface_deny_clipboard_request + /// instead. export fn ghostty_surface_complete_clipboard_request( ptr: *Surface, - str: [*:0]const u8, + complete: *const ClipboardComplete, state: *apprt.ClipboardRequest, - confirmed: bool, ) void { - ptr.completeClipboardRequest( - std.mem.sliceTo(str, 0), - state, - confirmed, - ); + ptr.completeClipboardRequest(complete, state); + } + + /// Deny a clipboard read request started via the read callback, + /// e.g. because the user rejected a confirmation prompt. Request + /// types whose protocol expects an answer have their denial reply + /// written to the pty. The request pointer is invalidated. + export fn ghostty_surface_deny_clipboard_request( + ptr: *Surface, + state: *apprt.ClipboardRequest, + ) void { + ptr.denyClipboardRequest(state); } export fn ghostty_surface_inspector(ptr: *Surface) ?*Inspector { diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index c5ba39277..4a59ab21d 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -74,7 +74,7 @@ pub fn clipboardRequest( self: *Self, clipboard_type: apprt.Clipboard, state: apprt.ClipboardRequest, -) !bool { +) !apprt.ClipboardReadResult { return try self.surface.clipboardRequest( clipboard_type, state, diff --git a/src/apprt/gtk/class/clipboard_confirmation_dialog.zig b/src/apprt/gtk/class/clipboard_confirmation_dialog.zig index d44d38a35..2cfe6fa09 100644 --- a/src/apprt/gtk/class/clipboard_confirmation_dialog.zig +++ b/src/apprt/gtk/class/clipboard_confirmation_dialog.zig @@ -197,7 +197,7 @@ pub const ClipboardConfirmationDialog = extern struct { self.as(Dialog.Parent).setHeading(i18n._("Authorize Clipboard Access")); self.as(Dialog.Parent).setBody(i18n._("An application is attempting to write to the clipboard. The current clipboard contents are shown below.")); }, - .osc_52_read => { + .osc_52_read, .kitty_read => { self.as(Dialog.Parent).setHeading(i18n._("Authorize Clipboard Access")); self.as(Dialog.Parent).setBody(i18n._("An application is attempting to read from the clipboard. The current clipboard contents are shown below.")); }, diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index ff0d9a7d2..709aedfa3 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -1725,7 +1725,7 @@ pub const Surface = extern struct { self: *Self, clipboard_type: apprt.Clipboard, state: apprt.ClipboardRequest, - ) !bool { + ) !apprt.ClipboardReadResult { return try Clipboard.request( self, clipboard_type, @@ -4111,22 +4111,23 @@ const Clipboard = struct { ); } - /// Request data from the clipboard (read the clipboard). This - /// completes asynchronously and will call the `completeClipboardRequest` - /// core surface API when done. - /// - /// Returns true if the request was started, false if the clipboard - /// doesn't contain text (allowing performable keybinds to pass through). + /// Request data from the clipboard (read the clipboard). A started + /// request completes asynchronously and will call the + /// `completeClipboardRequest` core surface API when done. pub fn request( self: *Surface, clipboard_type: apprt.Clipboard, state: apprt.ClipboardRequest, - ) Allocator.Error!bool { + ) Allocator.Error!apprt.ClipboardReadResult { + // The GTK apprt doesn't support Kitty clipboard protocol reads + // yet. + if (state == .kitty_read) return .unsupported; + // Get our requested clipboard const clipboard = get( self.private().gl_area.as(gtk.Widget), clipboard_type, - ) orelse return false; + ) orelse return .unsupported; // For paste requests, check if clipboard has text format available. // This is a synchronous check that allows performable keybinds to @@ -4135,7 +4136,7 @@ const Clipboard = struct { const formats = clipboard.getFormats(); if (formats.containGtype(gobject.ext.types.string) == 0) { log.debug("clipboard has no text format, not starting paste request", .{}); - return false; + return .unavailable; } } @@ -4158,7 +4159,7 @@ const Clipboard = struct { ud, ); - return true; + return .started; } /// Paste explicit text directly into the surface, regardless of the @@ -4172,8 +4173,7 @@ const Clipboard = struct { const surface = self.private().core_surface orelse return; surface.completeClipboardRequest( .paste, - text, - false, + .{ .contents = &.{.{ .mime = "text/plain", .data = text }} }, ) catch |err| switch (err) { error.UnsafePaste, error.UnauthorizedPaste, @@ -4224,7 +4224,7 @@ const Clipboard = struct { .request = &req, .@"can-remember" = switch (req) { .osc_52_read, .osc_52_write => true, - .paste => false, + .paste, .kitty_read => false, }, .@"clipboard-contents" = contents_buf, }, @@ -4261,7 +4261,7 @@ const Clipboard = struct { if (remember) switch (req.*) { .osc_52_read => surface.config.clipboard_read = .allow, .osc_52_write => surface.config.clipboard_write = .allow, - .paste => {}, + .paste, .kitty_read => {}, }; // Get our text @@ -4278,11 +4278,10 @@ const Clipboard = struct { ?[:0]const u8, ) orelse return; - surface.completeClipboardRequest( - req.*, - text, - true, - ) catch |err| { + surface.completeClipboardRequest(req.*, .{ + .contents = &.{.{ .mime = "text/plain", .data = text }}, + .confirmed = true, + }) catch |err| { log.warn("failed to complete clipboard request: {}", .{err}); }; } @@ -4300,7 +4299,7 @@ const Clipboard = struct { if (remember) switch (req.*) { .osc_52_read => surface.config.clipboard_read = .deny, .osc_52_write => surface.config.clipboard_write = .deny, - .paste => @panic("paste should not be able to be remembered"), + .paste, .kitty_read => @panic("request should not be able to be remembered"), }; } @@ -4338,8 +4337,7 @@ const Clipboard = struct { const surface = self.private().core_surface orelse return; surface.completeClipboardRequest( req.state, - str, - false, + .{ .contents = &.{.{ .mime = "text/plain", .data = str }} }, ) catch |err| switch (err) { error.UnsafePaste, error.UnauthorizedPaste, diff --git a/src/apprt/structs.zig b/src/apprt/structs.zig index 2c37dbd5e..825c85939 100644 --- a/src/apprt/structs.zig +++ b/src/apprt/structs.zig @@ -1,4 +1,6 @@ +const std = @import("std"); const build_config = @import("../build_config.zig"); +const terminal = @import("../terminal/main.zig"); /// ContentScale is the ratio between the current DPI and the platform's /// default DPI. This is used to determine how much certain rendered elements @@ -67,6 +69,26 @@ pub const ClipboardRequestType = enum(u8) { paste, osc_52_read, osc_52_write, + kitty_read, +}; + +/// The result of starting a clipboard read request. This only reports +/// facts about the clipboard; how each state is answered on the wire +/// (if at all) is up to the protocol handling of the requester. +/// +/// If this is changed, you must also update ghostty.h +pub const ClipboardReadResult = enum(c_int) { + /// The request was started and will be completed asynchronously + /// via the core surface completeClipboardRequest API. + started = 0, + + /// The clipboard exists but has no contents the apprt can serve + /// (e.g. no text-like data). The request was not started. + unavailable = 1, + + /// The clipboard itself can't be read (e.g. a primary selection on + /// a platform without one). The request was not started. + unsupported = 2, }; /// Clipboard request. This is used to request clipboard contents and must @@ -81,6 +103,59 @@ pub const ClipboardRequest = union(ClipboardRequestType) { /// A request to write clipboard contents via OSC 52. osc_52_write: Clipboard, + /// A request to read clipboard contents via the Kitty clipboard + /// protocol (OSC 5522). + kitty_read: *KittyRead, + + /// State for one in-flight Kitty clipboard protocol read. This is + /// created on the IO thread and completed on the app thread, so it + /// owns all of its memory: everything, including the struct itself, + /// is allocated from the arena. + pub const KittyRead = struct { + arena: std.heap.ArenaAllocator, + + /// The clipboard being read. The protocol can only name the + /// standard clipboard or the primary selection. + location: Clipboard, + + /// The requested MIME types in request order, already capped at + /// terminal.kitty.clipboard.max_read_mimes by the sender. Only + /// these representations may be served in the response. The + /// values are sentinel-terminated so they can cross a C apprt + /// boundary without copies. + mimes: []const [:0]const u8, + + /// True when the targets ('.') listing was requested. + list: bool, + + /// The sanitized request id, echoed in every response packet. + id: []const u8, + + /// The effective session password, empty when the request had + /// none. A non-empty password means the user's decision may be + /// remembered as a session grant. + pw: []const u8, + + /// The human friendly name of the requesting program, shown in + /// permission prompts. Empty when absent. Sentinel-terminated + /// so it can cross a C apprt boundary without copies. + name: [:0]const u8, + + /// True when a stored session grant already covers this + /// request, so any permission prompt is skipped. + granted: bool, + + /// The response terminator, matching the request's. + terminator: terminal.osc.Terminator, + + pub fn destroy(self: *KittyRead) void { + // The struct itself lives in the arena, so move the arena + // out before tearing it down. + var arena = self.arena; + arena.deinit(); + } + }; + /// Make this a valid gobject if we're in a GTK environment. pub const getGObjectType = switch (build_config.app_runtime) { .gtk => @import("gobject").ext.defineBoxed( diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index 4a0b8b377..d2ca9171a 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -72,6 +72,11 @@ pub const Message = union(enum) { /// Read the clipboard and write to the pty. clipboard_read: apprt.Clipboard, + /// A Kitty clipboard protocol (OSC 5522) read request. The receiver + /// takes ownership of the request state and must eventually destroy + /// it. + kitty_clipboard_read: *apprt.ClipboardRequest.KittyRead, + /// Write the clipboard contents. clipboard_write: struct { clipboard_type: apprt.Clipboard, diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 0f8a7cc4d..ba08ac999 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -717,6 +717,15 @@ fn processOutputLocked(self: *Termio, buf: []const u8) void { } /// Sends a DSR response for the current color scheme to the pty. +/// Record a Kitty clipboard protocol session grant so future requests +/// carrying the password skip the permission prompt. +pub fn kittyClipboardGrant(self: *Termio, pw: []const u8) !void { + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); + + try self.terminal_stream.handler.kittyClipboardGrant(pw); +} + pub fn colorSchemeReport(self: *Termio, td: *ThreadData, force: bool) !void { self.renderer_state.mutex.lockUncancelable(global.io()); defer self.renderer_state.mutex.unlock(global.io()); diff --git a/src/termio/Thread.zig b/src/termio/Thread.zig index 6b860a0d1..bb842142d 100644 --- a/src/termio/Thread.zig +++ b/src/termio/Thread.zig @@ -336,6 +336,10 @@ fn drainMailbox( } }, .jump_to_prompt => |v| try io.jumpToPrompt(v), + .kitty_clipboard_grant => |v| { + defer v.alloc.free(v.pw); + try io.kittyClipboardGrant(v.pw); + }, .start_synchronized_output => self.startSynchronizedOutput(cb), .linefeed_mode => |v| self.flags.linefeed_mode = v, .focused => |v| try io.focusGained(data, v), diff --git a/src/termio/message.zig b/src/termio/message.zig index e51865e39..f200be4ce 100644 --- a/src/termio/message.zig +++ b/src/termio/message.zig @@ -82,6 +82,14 @@ pub const Message = union(enum) { /// The surface gained or lost focus. focused: bool, + /// Record a Kitty clipboard protocol session grant for a password + /// so future requests carrying it skip the permission prompt. The + /// password is allocated and must be freed. + kitty_clipboard_grant: struct { + alloc: Allocator, + pw: []const u8, + }, + /// Write where the data fits in the union. write_small: WriteReq.Small, @@ -111,6 +119,7 @@ pub const Message = union(enum) { v.alloc.destroy(v.ptr); }, .write_alloc => |v| v.alloc.free(v.data), + .kitty_clipboard_grant => |v| v.alloc.free(v.pw), else => {}, } } diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 1dc5268f7..4609b49e6 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -68,6 +68,10 @@ pub const StreamHandler = struct { /// The tmux control mode viewer state. tmux_viewer: if (tmux_enabled) ?*terminal.tmux.Viewer else void = if (tmux_enabled) null else {}, + /// Session password grants for the Kitty clipboard protocol. + /// Requests carrying a granted password skip the permission prompt. + kitty_clipboard_grants: terminal.kitty.clipboard.Grants = .{}, + /// This is set to true when a message was written to the termio /// mailbox. This can be used by callers to determine if they need /// to wake up the termio thread. @@ -85,6 +89,7 @@ pub const StreamHandler = struct { pub fn deinit(self: *StreamHandler) void { self.apc.deinit(); self.dcs.deinit(); + self.kitty_clipboard_grants.deinit(self.alloc); if (comptime tmux_enabled) tmux: { const viewer = self.tmux_viewer orelse break :tmux; viewer.deinit(); @@ -352,11 +357,11 @@ pub const StreamHandler = struct { .apc_end => try self.apcEnd(), .apc_put => self.apc.feed(self.alloc, value), .apc_put_slice => self.apc.feedSlice(self.alloc, value.bytes), + .kitty_clipboard => try self.kittyClipboard(value), // Unimplemented .title_push, .title_pop, - .kitty_clipboard, .kitty_dnd, => {}, } @@ -869,6 +874,10 @@ pub const StreamHandler = struct { self.terminal.fullReset(); try self.setMouseShape(.text); + // Full reset clears Kitty clipboard session grants. + self.kitty_clipboard_grants.deinit(self.alloc); + self.kitty_clipboard_grants = .{}; + // Reset resets our palette so we report it for mode 2031. self.messageWriter(.{ .color_scheme_report = .{ .force = false } }); @@ -876,6 +885,12 @@ pub const StreamHandler = struct { self.progressReport(.{ .state = .remove }); } + /// Record a Kitty clipboard protocol session grant so future + /// requests with this password skip the permission prompt. + pub fn kittyClipboardGrant(self: *StreamHandler, pw: []const u8) !void { + try self.kitty_clipboard_grants.grant(self.alloc, pw, .read, false); + } + pub fn queryKittyKeyboard(self: *StreamHandler) !void { log.debug("querying kitty keyboard mode", .{}); var data: termio.Message.WriteReq.Small.Array = undefined; @@ -990,6 +1005,135 @@ pub const StreamHandler = struct { }); } + /// Handle one Kitty clipboard protocol (OSC 5522) packet. + fn kittyClipboard( + self: *StreamHandler, + v: terminal.osc.Command.KittyClipboardProtocol, + ) !void { + const kitty_clipboard = terminal.kitty.clipboard; + + // Decode and validate the metadata. Malformed metadata drops + // the packet without any response, matching kitty. + var arena: std.heap.ArenaAllocator = .init(self.alloc); + defer arena.deinit(); + const meta = (try kitty_clipboard.Metadata.parse( + arena.allocator(), + v.metadata, + )) orelse return; + + switch (meta.op) { + .read => try self.kittyClipboardRead( + &meta, + v.payload orelse "", + v.terminator, + ), + + // Writes aren't implemented in the GUI yet. Failing the + // transaction up front matches a libghostty-vt handler + // without a clipboard_write effect and spares the program + // from waiting on a commit response that never comes. + .write => { + var stream: std.Io.Writer.Allocating = .init(self.alloc); + defer stream.deinit(); + try (kitty_clipboard.Response{ + .op = .write, + .status = .ENOSYS, + .id = meta.id, + .terminator = v.terminator, + }).encode(&stream.writer); + self.messageWriter(.{ .write_alloc = .{ + .alloc = self.alloc, + .data = try stream.toOwnedSlice(), + } }); + }, + + // Data packets without an accepted write transaction are + // silently ignored, matching kitty. + .wdata, .walias => {}, + } + } + + fn kittyClipboardRead( + self: *StreamHandler, + meta: *const terminal.kitty.clipboard.Metadata, + payload: []const u8, + terminator: terminal.osc.Terminator, + ) !void { + const kitty_clipboard = terminal.kitty.clipboard; + + // Everything about the request, including the request struct + // itself, lives in a single arena that crosses to the surface + // thread, which owns it from the moment the message is sent. + var arena: std.heap.ArenaAllocator = .init(self.alloc); + errdefer arena.deinit(); + const alloc = arena.allocator(); + + // The payload is the requested MIME list. A read request with + // an undecodable payload is dropped without any response, + // matching kitty. + const decoded = kitty_clipboard.Payload.init( + alloc, + payload, + ) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.Invalid => { + arena.deinit(); + return; + }, + }; + + // The targets type ('.') asks for the listing of available + // types rather than data. Requested types beyond the cap are + // dropped and simply never served, which is how the protocol + // reports an unavailable type anyway. + var mimes_buf: [kitty_clipboard.max_read_mimes][]const u8 = undefined; + var mimes_len: usize = 0; + var list = false; + var it = decoded.mimeIterator(); + while (it.next()) |mime| { + if (std.mem.eql(u8, mime, kitty_clipboard.targets_mime)) { + list = true; + continue; + } + if (mimes_len == mimes_buf.len) continue; + mimes_buf[mimes_len] = mime; + mimes_len += 1; + } + + // Per the spec a password without a name is no password. A + // stored session grant for it lets the surface skip its + // permission prompt. + const pw: []const u8 = if (meta.name.len > 0) meta.pw else ""; + const granted = self.kitty_clipboard_grants.use(self.alloc, pw, .read); + + const req = try alloc.create(apprt.ClipboardRequest.KittyRead); + const mimes = try alloc.alloc([:0]const u8, mimes_len); + for (mimes_buf[0..mimes_len], mimes) |src, *dst| { + dst.* = try alloc.dupeZ(u8, src); + } + const id = try alloc.dupe(u8, meta.id); + const pw_owned = try alloc.dupe(u8, pw); + const name_owned = try alloc.dupeZ(u8, meta.name); + req.* = .{ + // The arena must be copied in last so it tracks every + // allocation above. + .arena = arena, + .location = switch (meta.loc) { + .primary => .primary, + else => .standard, + }, + .mimes = mimes, + .list = list, + .id = id, + .pw = pw_owned, + .name = name_owned, + .granted = granted, + .terminator = terminator, + }; + + self.surfaceMessageWriter(.{ .kitty_clipboard_read = req }); + } + fn semanticPrompt( self: *StreamHandler, cmd: Stream.Action.SemanticPrompt, diff --git a/typos.toml b/typos.toml index 46ac28dd8..215848410 100644 --- a/typos.toml +++ b/typos.toml @@ -59,6 +59,7 @@ extend-ignore-re = [ Pn = "Pn" thr = "thr" # Swift oddities +Datas = "Datas" Requestor = "Requestor" iterm = "iterm" ACCES = "ACCES"