diff --git a/docs/changelog.rst b/docs/changelog.rst index be2b6a892..1fa1a0f4b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -111,6 +111,10 @@ Detailed list of changes - A new :ref:`protocol extension ` to notify terminal programs that have turned on SGR Pixel mouse reporting when the mouse leaves the window (:disc:`8808`) +- clipboard kitten: Can now optionally take a password to avoid repeated + permission prompts when accessing the clipboard. Based on a + :ref:`protocol extension `. (:iss:`8789`) + - A new :option:`launch --hold-after-ssh` to not close a launched window that connects directly to a remote host because of :option:`launch --cwd`:code:`=current` when the connection ends (:pull:`8807`) diff --git a/docs/clipboard.rst b/docs/clipboard.rst index 2162e442c..b4bbfa852 100644 --- a/docs/clipboard.rst +++ b/docs/clipboard.rst @@ -138,6 +138,29 @@ the data, but create multiple references to it in the system clipboard. Alias packets can be sent anytime after the initial write packet and before the end of data packet. +.. _clipboard_repeated_permission: + +Avoiding repeated permission prompts +-------------------------------------- + +.. versionadded:: using a password to avoid repeated confirmations was added in version 0.43.0 + +If a program like an editor wants to make use of the system clipboard, by +default, the user is prompted on every read request. This can become quite +fatiguing. To avoid this situation, this protocol allows sending a password +and human friendly name with ``type=write`` and ``type=read`` requests. The +terminal can then ask the user to allow all future requests using that +password. If the user agrees, future requests on the same tty will be +automatically allowed by the terminal. The editor or other program using +this facility should ideally use a password randomnly generated at startup, +such as a UUID4. However, terminals may implement permanent/stored passwords. +Users can then configure terminal programs they trust to use these password. + +The password and the human name are encoded using the ``pw`` and ``name`` keys +in the metadata. The values are UTF-8 strings that are base64 encoded. +Specifying a password without a human friendly name is equivalent to not +specifying a password and the terminal must treat the request as though +it had no password. Support for terminal multiplexers ------------------------------------ diff --git a/kittens/clipboard/main.go b/kittens/clipboard/main.go index 225ed98e3..c7158837f 100644 --- a/kittens/clipboard/main.go +++ b/kittens/clipboard/main.go @@ -3,7 +3,12 @@ package clipboard import ( + "fmt" + "io" "os" + "strconv" + "strings" + "unicode" "github.com/kovidgoyal/kitty/tools/cli" ) @@ -20,10 +25,47 @@ func run_mime_loop(opts *Options, args []string) (err error) { } func clipboard_main(cmd *cli.Command, opts *Options, args []string) (rc int, err error) { + if opts.Password != "" { + if opts.HumanName == "" { + return 1, fmt.Errorf("must specify --human-name when using a password") + } + ptype, val, found := strings.Cut(opts.Password, ":") + if !found { + return 1, fmt.Errorf("invalid password: %#v no password type specified", opts.Password) + } + switch ptype { + case "text": + opts.Password = val + case "fd": + if fd, err := strconv.Atoi(val); err == nil { + if f := os.NewFile(uintptr(fd), "password-fd"); f == nil { + return 1, fmt.Errorf("invalid file descriptor: %d", fd) + } else { + data, err := io.ReadAll(f) + f.Close() + if err != nil { + return 1, fmt.Errorf("failed to read from file descriptor: %d with error: %w", fd, err) + } + opts.Password = strings.TrimRightFunc(string(data), unicode.IsSpace) + } + + } else { + return 1, fmt.Errorf("not a valid file descriptor number: %#v", val) + } + case "file": + if data, err := os.ReadFile(val); err == nil { + opts.Password = strings.TrimRightFunc(string(data), unicode.IsSpace) + } else { + return 1, fmt.Errorf("failed to read from file: %#v with error: %w", val, err) + } + } + } if len(args) > 0 { return 0, run_mime_loop(opts, args) } - + if opts.Password != "" || opts.HumanName != "" { + return 1, fmt.Errorf("cannot use --human-name or --password in filter mode") + } return 0, run_plain_text_loop(opts) } diff --git a/kittens/clipboard/main.py b/kittens/clipboard/main.py index 396d7771a..84a8fea68 100644 --- a/kittens/clipboard/main.py +++ b/kittens/clipboard/main.py @@ -44,6 +44,18 @@ other :code:`text/*` MIME is present. type=bool-set Wait till the copy to clipboard is complete before exiting. Useful if running the kitten in a dedicated, ephemeral window. Only needed in filter mode. + + +--password +A password to use when accessing the clipboard. If the user chooses to accept the password +future invocations of the kitten will not have a permission prompt in this tty session. Does not +work in filter mode. Must be of the form: text:actual-password or fd:integer (a file descriptor +number to read the password from) or file:path-to-file (a file from which to read the password). +Note that you must also specify a human friendly name using the :option:`--human-name` flag. + + +--human-name +A human friendly name to show the user when asking for permission to access the clipboard. '''.format help_text = '''\ Read or write to the system clipboard. diff --git a/kittens/clipboard/read.go b/kittens/clipboard/read.go index 55298002d..663433671 100644 --- a/kittens/clipboard/read.go +++ b/kittens/clipboard/read.go @@ -329,9 +329,14 @@ func run_get_loop(opts *Options, args []string) (err error) { if opts.UsePrimary { basic_metadata["loc"] = "primary" } - lp.OnInitialize = func() (string, error) { lp.QueueWriteString(encode(basic_metadata, ".")) + if opts.Password != "" { + basic_metadata["pw"] = base64.StdEncoding.EncodeToString(utils.UnsafeStringToBytes(opts.Password)) + } + if opts.HumanName != "" { + basic_metadata["name"] = base64.StdEncoding.EncodeToString(utils.UnsafeStringToBytes(opts.HumanName)) + } return "", nil } diff --git a/kittens/clipboard/write.go b/kittens/clipboard/write.go index 3ed0fa4eb..7714ca6aa 100644 --- a/kittens/clipboard/write.go +++ b/kittens/clipboard/write.go @@ -3,6 +3,7 @@ package clipboard import ( + "encoding/base64" "errors" "fmt" "io" @@ -80,6 +81,14 @@ func write_loop(inputs []*Input, opts *Options) (err error) { if mime != "" { ans["mime"] = mime } + if ptype == "write" { + if opts.Password != "" { + ans["pw"] = base64.StdEncoding.EncodeToString(utils.UnsafeStringToBytes(opts.Password)) + } + if opts.HumanName != "" { + ans["name"] = base64.StdEncoding.EncodeToString(utils.UnsafeStringToBytes(opts.HumanName)) + } + } return ans } diff --git a/kitty/clipboard.py b/kitty/clipboard.py index 39290f765..d371539eb 100644 --- a/kitty/clipboard.py +++ b/kitty/clipboard.py @@ -22,6 +22,7 @@ from .fast_data_types import ( get_options, set_clipboard_data_types, ) +from .typing_compat import WindowType from .utils import log_error READ_RESPONSE_CHUNK_SIZE = 4096 @@ -200,7 +201,7 @@ def encode_mime(x: str) -> str: def decode_metadata_value(k: str, x: str) -> str: - if k == 'mime': + if k in ('mime', 'name', 'pw'): import base64 x = base64.standard_b64decode(x).decode('utf-8') return x @@ -211,6 +212,8 @@ class ReadRequest(NamedTuple): mime_types: tuple[str, ...] = ('text/plain',) id: str = '' protocol_type: ProtocolType = ProtocolType.osc_52 + human_name: str = '' + password: str = '' def encode_response(self, status: str = 'DATA', mime: str = '', payload: bytes | memoryview = b'') -> bytes: ans = f'{self.protocol_type.value};type=read:status={status}' @@ -242,9 +245,11 @@ class WriteRequest: def __init__( self, is_primary_selection: bool = False, protocol_type: ProtocolType = ProtocolType.osc_52, id: str = '', - rollover_size: int = 16 * 1024 * 1024, max_size: int = -1, + rollover_size: int = 16 * 1024 * 1024, max_size: int = -1, human_name: str = '', password: str = '', ) -> None: self.decoder = StreamingBase64Decoder() + self.human_name = human_name + self.password = password self.id = id self.is_primary_selection = is_primary_selection self.protocol_type = protocol_type @@ -255,6 +260,8 @@ class WriteRequest: self.max_size = (get_options().clipboard_max_size * 1024 * 1024) if max_size < 0 else max_size self.aliases: dict[str, str] = {} self.committed = False + self.permission_pending = True + self.commit_pending = False def encode_response(self, status: str = 'OK') -> bytes: ans = f'{self.protocol_type.value};type=write:status={status}' @@ -266,7 +273,11 @@ class WriteRequest: def commit(self) -> None: if self.committed: return + if self.permission_pending: + self.commit_pending = True + return self.committed = True + self.commit_pending = False cp = get_boss().primary_selection if self.is_primary_selection else get_boss().clipboard if cp.enabled: for alias, src in self.aliases.items(): @@ -316,6 +327,13 @@ class WriteRequest: return self.tempfile.read(start+offset, size) +class GrantedPermission: + + def __init__(self, read: bool = False, write: bool = False): + self.read, self.write = read, write + self.write_ban = self.read_ban = False + + class ClipboardRequestManager: def __init__(self, window_id: int) -> None: @@ -323,6 +341,7 @@ class ClipboardRequestManager: self.currently_asking_permission_for: ReadRequest | None = None self.in_flight_write_request: WriteRequest | None = None self.osc52_in_flight_write_requests: dict[ClipboardType, WriteRequest] = {} + self.granted_passwords: dict[str, GrantedPermission] = {} def parse_osc_5522(self, data: memoryview) -> None: import base64 @@ -349,13 +368,15 @@ class ClipboardRequestManager: rr = ReadRequest( is_primary_selection=m.get('loc', '') == 'primary', mime_types=tuple(payload.decode('utf-8').split()), - protocol_type=ProtocolType.osc_5522, id=sanitize_id(m.get('id', '')) + protocol_type=ProtocolType.osc_5522, id=sanitize_id(m.get('id', '')), + human_name=m.get('name', ''), password=m.get('pw', ''), ) self.handle_read_request(rr) elif typ == 'write': self.in_flight_write_request = WriteRequest( is_primary_selection=m.get('loc', '') == 'primary', - protocol_type=ProtocolType.osc_5522, id=sanitize_id(m.get('id', '')) + protocol_type=ProtocolType.osc_5522, id=sanitize_id(m.get('id', '')), + human_name=m.get('name', ''), password=m.get('pw', ''), ) self.handle_write_request(self.in_flight_write_request) elif typ == 'walias': @@ -385,11 +406,17 @@ class ClipboardRequestManager: self.in_flight_write_request = None raise else: - wr.flush_base64_data() - wr.commit() - self.in_flight_write_request = None - if w is not None: - w.screen.send_escape_code_to_child(ESC_OSC, wr.encode_response(status='DONE')) + self.commit_write_request(wr) + + def commit_write_request(self, wr: WriteRequest, needs_flush: bool = True) -> None: + if needs_flush: + wr.flush_base64_data() + wr.commit() + if wr.committed: + self.in_flight_write_request = None + w = get_boss().window_id_map.get(self.window_id) + if w is not None: + w.screen.send_escape_code_to_child(ESC_OSC, wr.encode_response(status='DONE')) def parse_osc_52(self, data: memoryview, is_partial: bool = False) -> None: idx = find_in_memoryview(data, ord(b';')) @@ -423,15 +450,78 @@ class ClipboardRequestManager: self.fulfill_write_request(wr, allowed) def fulfill_write_request(self, wr: WriteRequest, allowed: bool = True) -> None: + wr.permission_pending = not allowed if wr.protocol_type is ProtocolType.osc_52: self.fulfill_legacy_write_request(wr, allowed) return - w = get_boss().window_id_map.get(self.window_id) cp = get_boss().primary_selection if wr.is_primary_selection else get_boss().clipboard - if not allowed or not cp.enabled: + w = get_boss().window_id_map.get(self.window_id) + if w is None: self.in_flight_write_request = None - if w is not None: - w.screen.send_escape_code_to_child(ESC_OSC, wr.encode_response(status='EPERM' if not allowed else 'ENOSYS')) + return + if not cp.enabled: + self.in_flight_write_request = None + w.screen.send_escape_code_to_child(ESC_OSC, wr.encode_response(status='ENOSYS')) + return + if not allowed: + if wr.password and wr.human_name: + if self.password_is_allowed_already(wr.password, for_write=True): + wr.permission_pending = False + else: + wid = w.id + def callback(granted: bool) -> None: + if wr is not self.in_flight_write_request: + return + if granted: + wr.permission_pending = False + if wr.commit_pending: + self.commit_write_request(wr, needs_flush=False) + else: + w = get_boss().window_id_map.get(wid) + if w is not None: + w.screen.send_escape_code_to_child(ESC_OSC, wr.encode_response(status='EPERM')) + self.in_flight_write_request = None + + self.request_permission(w, wr.human_name, wr.password, callback, for_write=True) + else: + self.in_flight_write_request = None + w.screen.send_escape_code_to_child(ESC_OSC, wr.encode_response(status='EPERM')) + + def request_permission(self, window: WindowType, human_name: str, password: str, callback: Callable[[bool], None], for_write: bool = False) -> None: + if (gp := self.granted_passwords.get(password)) and (gp.write_ban if for_write else gp.read_ban): + callback(False) + return + + def cb(q: str) -> None: + p = self.granted_passwords.get(password) + if p is None: + p = self.granted_passwords[password] = GrantedPermission() + callback(q in ('a', 'w')) + match q: + case 'w': + if for_write: + p.write = True + else: + p.read = True + case 'b': + if for_write: + p.write = False + p.write_ban = True + else: + p.read = False + p.read_ban = True + if for_write: + msg = _('The program {0} running in this window wants to write to the system clipboard.') + else: + msg = _('The program {0} running in this window wants to read from the system clipboard.') + msg += '\n\n' + ('If you choose "Always" similar requests from this program will be automatically allowed for the rest of this session.') + msg += '\n\n' + ('If you choose "Ban" similar requests from this program will be automatically dis-allowed for the rest of this session.') + from kittens.tui.operations import styled + get_boss().choose(msg.format(styled(human_name, fg='yellow')), cb, 'a;green:Allow', 'w;yellow:Always', 'd;red:Deny', 'b;red:Ban', + default='d', window=window, title=_('A program wants to access the clipboard')) + + def password_is_allowed_already(self, password: str, for_write: bool = False) -> bool: + return (q := self.granted_passwords.get(password)) is not None and (q.write if for_write else q.read) def fulfill_legacy_write_request(self, wr: WriteRequest, allowed: bool = True) -> None: cp = get_boss().primary_selection if wr.is_primary_selection else get_boss().clipboard @@ -517,11 +607,24 @@ class ClipboardRequestManager: w = get_boss().window_id_map.get(self.window_id) if w is not None: self.currently_asking_permission_for = rr - get_boss().confirm(_( - 'A program running in this window wants to read from the system clipboard.' - ' Allow it to do so, once?'), - self.handle_clipboard_confirmation, window=w, - ) + if rr.password and rr.human_name: + if self.password_is_allowed_already(rr.password): + self.handle_clipboard_confirmation(True) + return + if (p := self.granted_passwords.get(rr.password)) and p.read_ban: + self.handle_clipboard_confirmation(False) + return + self.request_permission(w, rr.human_name, rr.password, self.handle_clipboard_confirmation) + else: + if rr.human_name: + msg = _( + 'The program {} running in this window wants to read from the system clipboard.' + ' Allow it to do so, once?').format(rr.human_name) + else: + msg = _( + 'A program running in this window wants to read from the system clipboard.' + ' Allow it to do so, once?') + get_boss().confirm(msg, self.handle_clipboard_confirmation, window=w) def handle_clipboard_confirmation(self, confirmed: bool) -> None: rr = self.currently_asking_permission_for