diff --git a/include/ghostty.h b/include/ghostty.h index 3959f46a6..e467fa0ea 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -85,6 +85,29 @@ typedef struct { 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, @@ -1043,10 +1066,7 @@ typedef ghostty_clipboard_read_result_e (*ghostty_runtime_read_clipboard_cb)( bool); typedef void (*ghostty_runtime_confirm_read_clipboard_cb)( void*, - const ghostty_clipboard_content_s*, - size_t, - const char* const*, - size_t, + const ghostty_clipboard_confirm_s*, void*, ghostty_clipboard_request_e); typedef void (*ghostty_runtime_write_clipboard_cb)(void*, @@ -1198,12 +1218,8 @@ 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 ghostty_clipboard_content_s*, - size_t, - const char* const*, - size_t, - void*, - bool); + 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); diff --git a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift index 897ada880..cd91d0824 100644 --- a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift +++ b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationController.swift @@ -54,6 +54,8 @@ class ClipboardConfirmationController: NSWindowController { window.contentView = NSHostingView(rootView: ClipboardConfirmationView( contents: confirmation.contents, request: confirmation.kind, + programName: confirmation.programName, + canRemember: confirmation.canRemember, delegate: delegate )) } diff --git a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift index e4e78b79e..c66e0f0e8 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. @@ -31,9 +31,20 @@ 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 + /// 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,7 +57,7 @@ struct ClipboardConfirmationView: View { .padding() .frame(alignment: .center) - Text(request.text()) + Text(request.text(name: programName)) .frame(maxWidth: .infinity, alignment: .leading) .padding() } @@ -55,6 +66,11 @@ struct ClipboardConfirmationView: View { .focusable(false) .font(.system(.body, design: .monospaced)) + if canRemember { + Toggle("Remember this choice for the session", isOn: $remember) + .padding(.top, 4) + } + HStack { Spacer() Button(Action.text(.cancel, request)) { onCancel() } @@ -87,10 +103,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 72fe703ab..77a3fa1d7 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -68,13 +68,10 @@ extension Ghostty { mimes: mimes, mimesLen: mimesLen, list: list) }, - confirm_read_clipboard_cb: { userdata, contents, contentsLen, available, availableLen, state, request in + confirm_read_clipboard_cb: { userdata, confirm, state, request in App.confirmReadClipboard( userdata, - contents: contents, - contentsLen: contentsLen, - available: available, - availableLen: availableLen, + confirm: confirm, state: state, request: request) }, write_clipboard_cb: { userdata, loc, content, len, confirm in @@ -352,38 +349,37 @@ extension Ghostty { static func confirmReadClipboard( _ userdata: UnsafeMutableRawPointer?, - contents: UnsafePointer?, - contentsLen: Int, - available: UnsafePointer?>?, - availableLen: Int, + confirm: UnsafePointer?, state: UnsafeMutableRawPointer?, request: ghostty_clipboard_request_e ) { let surfaceView = self.surfaceUserdata(from: userdata) guard let surface = surfaceView.surface else { return } - guard let kind = Ghostty.ClipboardRequest.from(request: request) else { + 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 { - for i in 0.. 0 { - Data(bytes: c.data, count: c.len) + 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: c.mime), data: data)) + reps.append(.init(mime: String(cString: content.mime), data: data)) } } var avail: [String] = [] - if let available { - for i in 0..] = [] @@ -461,14 +461,14 @@ extension Ghostty { cContents.withUnsafeBufferPointer { contentsBuf in cAvailable.withUnsafeBufferPointer { availableBuf in - ghostty_surface_complete_clipboard_request( - surface, - contentsBuf.baseAddress, - contentsBuf.count, - availableBuf.baseAddress, - availableBuf.count, - state, - confirmed) + 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) } } } @@ -521,7 +521,7 @@ extension Ghostty { surface: surfaceView, contents: textPlainString, kind: .osc_52_write - ) { _, confirmed in + ) { _, confirmed, _ in guard confirmed else { return } pasteboard.declareTypes([.string], owner: nil) pasteboard.setString(textPlainString, forType: .string) diff --git a/macos/Sources/Ghostty/Ghostty.ClipboardConfirmationRequest.swift b/macos/Sources/Ghostty/Ghostty.ClipboardConfirmationRequest.swift index 89fa61fd3..d41461fa1 100644 --- a/macos/Sources/Ghostty/Ghostty.ClipboardConfirmationRequest.swift +++ b/macos/Sources/Ghostty/Ghostty.ClipboardConfirmationRequest.swift @@ -17,8 +17,11 @@ extension Ghostty { /// the Kitty clipboard protocol (OSC 5522). case kitty_read - /// The text to show in the clipboard confirmation prompt for this request. - func text() -> String { + /// 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 """ @@ -26,12 +29,12 @@ extension Ghostty { """ 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. """ } @@ -70,18 +73,31 @@ extension Ghostty { let kind: ClipboardRequest - /// Called exactly once with whether the user confirmed the request. - private var completion: ((SurfaceView, Bool) -> 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 + + /// 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, Bool) -> Void + programName: String? = nil, + canRemember: Bool = false, + completion: @escaping (SurfaceView, Bool, Bool) -> Void ) { self.surface = surface self.contents = contents self.kind = kind + self.programName = programName + self.canRemember = canRemember self.completion = completion } @@ -89,13 +105,13 @@ extension Ghostty { guard let surface, let completion else { return } self.completion = nil DispatchQueue.main.async { - completion(surface, false) + completion(surface, false, false) } } /// Complete the request with the displayed clipboard contents. - func complete() { - finish(true) + func complete(remember: Bool = false) { + finish(true, remember: remember) } /// Cancel the request, denying access to the clipboard contents. @@ -112,6 +128,7 @@ extension Ghostty { private func finish( _ confirmed: Bool, + remember: Bool = false, on explicitSurface: SurfaceView? = nil ) { guard let surface = explicitSurface ?? self.surface, @@ -120,7 +137,7 @@ extension Ghostty { return } self.completion = nil - completion(surface, confirmed) + completion(surface, confirmed, remember) } } } diff --git a/src/Surface.zig b/src/Surface.zig index 4a2b8a335..626d514d5 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -5847,68 +5847,110 @@ 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. All contents are immediately -/// copied so it is safe to free them after this call. +/// only be called once for each request. /// -/// The contents are the representations the apprt could serve for the -/// request's MIME types and `available` is the listing of MIME types on -/// the clipboard (only gathered when the request asked for it). -/// Requesters that only carry text (paste, OSC 52) use the first -/// text-like representation. -/// -/// 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 and Kitty clipboard protocol 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, - contents: []const terminal.clipboard.Content, - available: []const []const u8, - confirmed: bool, + complete: CompleteClipboard, ) !void { switch (req) { .paste => try self.completeClipboardPaste( - clipboardTextContent(contents) orelse "", - confirmed, + clipboardTextContent(complete.contents) orelse "", + complete.confirmed, ), .osc_52_read => |clipboard| try self.completeClipboardReadOSC52( - clipboardTextContent(contents) orelse "", + clipboardTextContent(complete.contents) orelse "", clipboard, - confirmed, + 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(contents) orelse ""); + 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, - }}, !confirmed); + }}, !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. - if (self.config.clipboard_read == .ask and !confirmed) { + // 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(); - try self.completeKittyClipboardRead(kitty, contents, available); + + // 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, + ); }, } } diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 88713174a..16c3d2cc1 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -77,14 +77,11 @@ pub const App = struct { /// 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 (usually with - /// these same contents, which are only borrowed for this call) or - /// deny_clipboard_request with the given request. + /// 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, - ?[*]const CAPI.ClipboardContent, - usize, - ?[*]const [*:0]const u8, - usize, + *const CAPI.ClipboardConfirm, *apprt.ClipboardRequest, apprt.ClipboardRequestType, ) callconv(.c) void, @@ -767,12 +764,8 @@ pub const Surface = struct { fn completeClipboardRequest( self: *Surface, - contents_: ?[*]const CAPI.ClipboardContent, - contents_len: usize, - available_: ?[*]const [*:0]const u8, - available_len: usize, + complete: *const CAPI.ClipboardComplete, state: *apprt.ClipboardRequest, - confirmed: bool, ) void { const alloc = self.app.core_app.alloc; @@ -782,7 +775,7 @@ pub const Surface = struct { const conv_alloc = stack.get(); const raw_contents: []const CAPI.ClipboardContent = - if (contents_) |v| v[0..contents_len] else &.{}; + if (complete.contents) |v| v[0..complete.contents_len] else &.{}; const contents = conv_alloc.alloc( terminal.clipboard.Content, raw_contents.len, @@ -798,7 +791,7 @@ pub const Surface = struct { }; const raw_available: []const [*:0]const u8 = - if (available_) |v| v[0..available_len] else &.{}; + if (complete.available) |v| v[0..complete.available_len] else &.{}; const available = conv_alloc.alloc( []const u8, raw_available.len, @@ -814,21 +807,35 @@ pub const Surface = struct { // Attempt to complete the request, but we may request // confirmation. - self.core_surface.completeClipboardRequest( - state.*, - contents, - available, - 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, - contents_, - contents_len, - available_, - available_len, + &.{ + .contents = complete.contents, + .contents_len = complete.contents_len, + .available = complete.available, + .available_len = complete.available_len, + .name = name, + .can_remember = can_remember, + }, state, state.*, ); @@ -1412,6 +1419,41 @@ pub const CAPI = struct { 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 const Text = extern struct { tl_px_x: f64, @@ -2112,21 +2154,10 @@ pub const CAPI = struct { /// instead. export fn ghostty_surface_complete_clipboard_request( ptr: *Surface, - contents: ?[*]const ClipboardContent, - contents_len: usize, - available: ?[*]const [*:0]const u8, - available_len: usize, + complete: *const ClipboardComplete, state: *apprt.ClipboardRequest, - confirmed: bool, ) void { - ptr.completeClipboardRequest( - contents, - contents_len, - available, - available_len, - state, - confirmed, - ); + ptr.completeClipboardRequest(complete, state); } /// Deny a clipboard read request started via the read callback, diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 6ca091396..709aedfa3 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -4173,9 +4173,7 @@ const Clipboard = struct { const surface = self.private().core_surface orelse return; surface.completeClipboardRequest( .paste, - &.{.{ .mime = "text/plain", .data = text }}, - &.{}, - false, + .{ .contents = &.{.{ .mime = "text/plain", .data = text }} }, ) catch |err| switch (err) { error.UnsafePaste, error.UnauthorizedPaste, @@ -4280,12 +4278,10 @@ const Clipboard = struct { ?[:0]const u8, ) orelse return; - surface.completeClipboardRequest( - req.*, - &.{.{ .mime = "text/plain", .data = 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}); }; } @@ -4341,9 +4337,7 @@ const Clipboard = struct { const surface = self.private().core_surface orelse return; surface.completeClipboardRequest( req.state, - &.{.{ .mime = "text/plain", .data = 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 1fd860bb2..825c85939 100644 --- a/src/apprt/structs.zig +++ b/src/apprt/structs.zig @@ -131,6 +131,20 @@ pub const ClipboardRequest = union(ClipboardRequestType) { /// 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, 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 c3f7c3603..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(); @@ -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; @@ -1085,9 +1100,11 @@ pub const StreamHandler = struct { mimes_len += 1; } - // Note: session grants (the pw/name metadata) aren't - // implemented in the GUI clipboard path yet, so every request - // goes through the configured clipboard-read policy. + // 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); @@ -1095,6 +1112,8 @@ pub const StreamHandler = struct { 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. @@ -1106,6 +1125,9 @@ pub const StreamHandler = struct { .mimes = mimes, .list = list, .id = id, + .pw = pw_owned, + .name = name_owned, + .granted = granted, .terminator = terminator, };