Reading arbitrary MIME from clipboard now works
This commit is contained in:
parent
f6ab641b00
commit
f29ce19097
@ -4,17 +4,25 @@
|
||||
import sys
|
||||
|
||||
OPTIONS = r'''
|
||||
--get-clipboard
|
||||
--get-clipboard -g
|
||||
type=bool-set
|
||||
Output the current contents of the clipboard to STDOUT. Note that by default
|
||||
kitty will prompt for permission to access the clipboard. Can be controlled
|
||||
by :opt:`clipboard_control`.
|
||||
|
||||
|
||||
--use-primary
|
||||
--use-primary -p
|
||||
type=bool-set
|
||||
Use the primary selection rather than the clipboard on systems that support it,
|
||||
such as X11.
|
||||
such as Linux.
|
||||
|
||||
|
||||
--mime -m
|
||||
type=list
|
||||
The mimetype of the specified file. Useful when the auto-detected mimetype is
|
||||
likely to be incorrect or the filename has no extension and therefore no mimetype
|
||||
can be detected. If more than one file is specified, this option should be specified multiple
|
||||
times, once for each specified file.
|
||||
|
||||
|
||||
--wait-for-completion
|
||||
|
||||
@ -257,8 +257,9 @@ class ClipboardRequestManager:
|
||||
self.in_flight_write_request: Optional[WriteRequest] = None
|
||||
|
||||
def parse_osc_5522(self, data: str) -> None:
|
||||
import base64
|
||||
from .notify import sanitize_id
|
||||
metadata, _, payload = data.partition(';')
|
||||
metadata, _, epayload = data.partition(';')
|
||||
m: Dict[str, str] = {}
|
||||
for record in metadata.split(':'):
|
||||
try:
|
||||
@ -268,10 +269,11 @@ class ClipboardRequestManager:
|
||||
return
|
||||
m[k] = v
|
||||
typ = m.get('type', '')
|
||||
payload = base64.standard_b64decode(epayload)
|
||||
if typ == 'read':
|
||||
rr = ReadRequest(
|
||||
is_primary_selection=m.get('loc', '') == 'primary',
|
||||
mime_types=tuple(payload.split()),
|
||||
mime_types=tuple(payload.decode('utf-8').split()),
|
||||
protocol_type=ProtocolType.osc_5522, id=sanitize_id(m.get('id', ''))
|
||||
)
|
||||
self.handle_read_request(rr)
|
||||
|
||||
@ -3,9 +3,22 @@
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"kitty/tools/cli"
|
||||
)
|
||||
|
||||
func run_mime_loop(opts *Options, args []string) (err error) {
|
||||
cwd, err = os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.GetClipboard {
|
||||
return run_get_loop(opts, args)
|
||||
}
|
||||
return run_set_loop(opts, args)
|
||||
}
|
||||
|
||||
func clipboard_main(cmd *cli.Command, opts *Options, args []string) (rc int, err error) {
|
||||
if len(args) > 0 {
|
||||
return 0, run_mime_loop(opts, args)
|
||||
|
||||
@ -1,115 +0,0 @@
|
||||
// License: GPLv3 Copyright: 2022, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"kitty/tools/tui/loop"
|
||||
"kitty/tools/utils"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
const OSC_NUMBER = "5522"
|
||||
|
||||
func run_get_loop(opts *Options, args []string) (err error) {
|
||||
lp, err := loop.New(loop.NoAlternateScreen, loop.NoRestoreColors, loop.NoMouseTracking)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var available_mimes []string
|
||||
reading_available_mimes := true
|
||||
|
||||
lp.OnInitialize = func() (string, error) {
|
||||
lp.QueueWriteString("\x1b]" + OSC_NUMBER + ";type=read;.\x1b\\")
|
||||
return "", nil
|
||||
}
|
||||
|
||||
lp.OnEscapeCode = func(etype loop.EscapeCodeType, data []byte) (err error) {
|
||||
if etype != loop.OSC || !bytes.HasPrefix(data, utils.UnsafeStringToBytes(OSC_NUMBER+";")) {
|
||||
return
|
||||
}
|
||||
parts := bytes.SplitN(data, utils.UnsafeStringToBytes(";"), 3)
|
||||
metadata := make(map[string]string)
|
||||
var payload []byte
|
||||
if len(parts) > 2 && len(parts[2]) > 0 {
|
||||
payload, err = base64.StdEncoding.DecodeString(utils.UnsafeBytesToString(parts[2]))
|
||||
if err != nil {
|
||||
err = fmt.Errorf("Received OSC %s packet from terminal with invalid base64 encoded payload", OSC_NUMBER)
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(parts) > 1 {
|
||||
for _, record := range bytes.Split(parts[1], utils.UnsafeStringToBytes(":")) {
|
||||
rp := bytes.SplitN(record, utils.UnsafeStringToBytes("="), 2)
|
||||
v := ""
|
||||
if len(rp) == 2 {
|
||||
v = string(rp[1])
|
||||
}
|
||||
metadata[string(rp[0])] = v
|
||||
}
|
||||
}
|
||||
if reading_available_mimes {
|
||||
switch metadata["status"] {
|
||||
case "DATA":
|
||||
available_mimes = strings.Split(utils.UnsafeBytesToString(payload), " ")
|
||||
case "OK":
|
||||
case "DONE":
|
||||
reading_available_mimes = false
|
||||
if len(available_mimes) == 0 {
|
||||
return fmt.Errorf("The clipboard is empty")
|
||||
}
|
||||
return fmt.Errorf("TODO: Implement processing available mimes")
|
||||
default:
|
||||
return fmt.Errorf("Failed to read list of available data types in the clipboard with error: %s", metadata["status"])
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
esc_count := 0
|
||||
lp.OnKeyEvent = func(event *loop.KeyEvent) error {
|
||||
if event.MatchesPressOrRepeat("ctrl+c") || event.MatchesPressOrRepeat("esc") {
|
||||
event.Handled = true
|
||||
esc_count++
|
||||
if esc_count < 2 {
|
||||
key := "Esc"
|
||||
if event.MatchesPressOrRepeat("ctrl+c") {
|
||||
key = "Ctrl+C"
|
||||
}
|
||||
lp.QueueWriteString(fmt.Sprintf("Waiting for response from terminal, press %s again to abort. This could cause garbage to be spewed to the screen.\r\n", key))
|
||||
} else {
|
||||
return fmt.Errorf("Aborted by user!")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
err = lp.Run()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ds := lp.DeathSignalName()
|
||||
if ds != "" {
|
||||
fmt.Println("Killed by signal: ", ds)
|
||||
lp.KillIfSignalled()
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func run_set_loop(opts *Options, args []string) (err error) {
|
||||
return fmt.Errorf("TODO: Implement me")
|
||||
}
|
||||
|
||||
func run_mime_loop(opts *Options, args []string) error {
|
||||
if opts.GetClipboard {
|
||||
return run_get_loop(opts, args)
|
||||
}
|
||||
return run_set_loop(opts, args)
|
||||
}
|
||||
376
tools/cmd/clipboard/read.go
Normal file
376
tools/cmd/clipboard/read.go
Normal file
@ -0,0 +1,376 @@
|
||||
// License: GPLv3 Copyright: 2022, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/gif"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"mime"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"kitty/tools/tty"
|
||||
"kitty/tools/tui/loop"
|
||||
"kitty/tools/utils"
|
||||
|
||||
"golang.org/x/image/bmp"
|
||||
"golang.org/x/image/tiff"
|
||||
_ "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
var cwd string
|
||||
|
||||
const OSC_NUMBER = "5522"
|
||||
|
||||
var decodable_image_types = map[string]bool{
|
||||
"image/jpeg": true, "image/png": true, "image/bmp": true, "image/tiff": true, "image/webp": true, "image/gif": true,
|
||||
}
|
||||
|
||||
var encodable_image_types = map[string]bool{
|
||||
"image/jpeg": true, "image/png": true, "image/bmp": true, "image/tiff": true, "image/gif": true,
|
||||
}
|
||||
|
||||
type Output struct {
|
||||
arg string
|
||||
ext string
|
||||
arg_is_stream bool
|
||||
mime_type string
|
||||
remote_mime_type string
|
||||
image_needs_conversion bool
|
||||
is_stream bool
|
||||
dest_is_tty bool
|
||||
dest *os.File
|
||||
err error
|
||||
started bool
|
||||
all_data_received bool
|
||||
}
|
||||
|
||||
func (self *Output) cleanup() {
|
||||
if self.dest != nil {
|
||||
self.dest.Close()
|
||||
if !self.is_stream {
|
||||
os.Remove(self.dest.Name())
|
||||
}
|
||||
self.dest = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (self *Output) add_data(data []byte) {
|
||||
if self.err != nil {
|
||||
return
|
||||
}
|
||||
if self.dest == nil {
|
||||
if !self.image_needs_conversion && self.arg_is_stream {
|
||||
self.is_stream = true
|
||||
self.dest = os.Stdout
|
||||
if self.arg == "/dev/stderr" {
|
||||
self.dest = os.Stderr
|
||||
}
|
||||
self.dest_is_tty = tty.IsTerminal(self.dest.Fd())
|
||||
} else {
|
||||
d := cwd
|
||||
if strings.ContainsRune(self.arg, os.PathSeparator) && !self.arg_is_stream {
|
||||
d = filepath.Dir(self.arg)
|
||||
}
|
||||
f, err := os.CreateTemp(d, "."+filepath.Base(self.arg))
|
||||
if err != nil {
|
||||
self.err = err
|
||||
return
|
||||
}
|
||||
self.dest = f
|
||||
}
|
||||
self.started = true
|
||||
}
|
||||
if self.dest_is_tty {
|
||||
data = bytes.ReplaceAll(data, utils.UnsafeStringToBytes("\n"), utils.UnsafeStringToBytes("\r\n"))
|
||||
}
|
||||
_, self.err = self.dest.Write(data)
|
||||
}
|
||||
|
||||
func (self *Output) write_image(img image.Image) (err error) {
|
||||
var output *os.File
|
||||
if self.arg_is_stream {
|
||||
output = os.Stdout
|
||||
if self.arg == "/dev/stderr" {
|
||||
output = os.Stderr
|
||||
}
|
||||
} else {
|
||||
output, err = os.Create(self.arg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
output.Close()
|
||||
if err != nil && !self.arg_is_stream {
|
||||
os.Remove(output.Name())
|
||||
}
|
||||
}()
|
||||
switch self.mime_type {
|
||||
case "image/png":
|
||||
return png.Encode(output, img)
|
||||
case "image/jpeg":
|
||||
return jpeg.Encode(output, img, nil)
|
||||
case "image/bmp":
|
||||
return bmp.Encode(output, img)
|
||||
case "image/gif":
|
||||
return gif.Encode(output, img, nil)
|
||||
case "image/tiff":
|
||||
return tiff.Encode(output, img, nil)
|
||||
}
|
||||
err = fmt.Errorf("Unsupported output image MIME type %s", self.mime_type)
|
||||
return
|
||||
}
|
||||
|
||||
func (self *Output) commit() {
|
||||
if self.err != nil {
|
||||
return
|
||||
}
|
||||
if self.image_needs_conversion {
|
||||
self.dest.Seek(0, os.SEEK_SET)
|
||||
img, _, err := image.Decode(self.dest)
|
||||
self.dest.Close()
|
||||
os.Remove(self.dest.Name())
|
||||
if err == nil {
|
||||
err = self.write_image(img)
|
||||
}
|
||||
if err != nil {
|
||||
self.err = fmt.Errorf("Failed to encode image data to %s with error: %w", self.mime_type, err)
|
||||
}
|
||||
} else {
|
||||
self.dest.Close()
|
||||
if !self.is_stream {
|
||||
self.err = os.Rename(self.dest.Name(), self.arg)
|
||||
if self.err != nil {
|
||||
os.Remove(self.dest.Name())
|
||||
self.err = fmt.Errorf("Failed to rename temporary file used for downloading to destination: %s with error: %w", self.arg, self.err)
|
||||
}
|
||||
}
|
||||
}
|
||||
self.dest = nil
|
||||
}
|
||||
|
||||
func (self *Output) assign_mime_type(available_mimes []string) (err error) {
|
||||
if utils.Contains(available_mimes, self.mime_type) {
|
||||
self.remote_mime_type = self.mime_type
|
||||
return
|
||||
}
|
||||
if self.mime_type == "." {
|
||||
self.remote_mime_type = "."
|
||||
return
|
||||
}
|
||||
if encodable_image_types[self.mime_type] {
|
||||
for _, mt := range available_mimes {
|
||||
if decodable_image_types[mt] {
|
||||
self.remote_mime_type = mt
|
||||
self.image_needs_conversion = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("The MIME type %s for %s not available on the clipboard", self.mime_type, self.arg)
|
||||
}
|
||||
|
||||
func encode(metadata map[string]string, payload string) string {
|
||||
ans := strings.Builder{}
|
||||
ans.Grow(2048)
|
||||
ans.WriteString("\x1b]")
|
||||
ans.WriteString(OSC_NUMBER)
|
||||
ans.WriteString(";")
|
||||
for k, v := range metadata {
|
||||
if !strings.HasSuffix(ans.String(), ";") {
|
||||
ans.WriteString(":")
|
||||
}
|
||||
ans.WriteString(k)
|
||||
ans.WriteString("=")
|
||||
ans.WriteString(v)
|
||||
}
|
||||
if len(payload) > 0 {
|
||||
ans.WriteString(";")
|
||||
ans.WriteString(base64.StdEncoding.EncodeToString(utils.UnsafeStringToBytes(payload)))
|
||||
}
|
||||
ans.WriteString("\x1b\\")
|
||||
return ans.String()
|
||||
}
|
||||
|
||||
func run_get_loop(opts *Options, args []string) (err error) {
|
||||
lp, err := loop.New(loop.NoAlternateScreen, loop.NoRestoreColors, loop.NoMouseTracking)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var available_mimes []string
|
||||
var wg sync.WaitGroup
|
||||
var getting_data_for string
|
||||
requested_mimes := make(map[string]*Output)
|
||||
reading_available_mimes := true
|
||||
outputs := make([]*Output, len(args))
|
||||
|
||||
for i, arg := range args {
|
||||
outputs[i] = &Output{arg: arg, arg_is_stream: arg == "/dev/stdout" || arg == "/dev/stderr", ext: filepath.Ext(arg)}
|
||||
if len(opts.Mime) > i {
|
||||
outputs[i].mime_type = opts.Mime[i]
|
||||
} else {
|
||||
if outputs[i].arg_is_stream {
|
||||
outputs[i].mime_type = "text/plain"
|
||||
} else {
|
||||
outputs[i].mime_type = mime.TypeByExtension(outputs[i].ext)
|
||||
}
|
||||
}
|
||||
if outputs[i].mime_type == "" {
|
||||
return fmt.Errorf("Could not detect the MIME type for: %s use --mime to specify it manually", arg)
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
for _, o := range outputs {
|
||||
if o.dest != nil {
|
||||
o.cleanup()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
lp.OnInitialize = func() (string, error) {
|
||||
lp.QueueWriteString(encode(map[string]string{"type": "read"}, "."))
|
||||
return "", nil
|
||||
}
|
||||
|
||||
lp.OnEscapeCode = func(etype loop.EscapeCodeType, data []byte) (err error) {
|
||||
if etype != loop.OSC || !bytes.HasPrefix(data, utils.UnsafeStringToBytes(OSC_NUMBER+";")) {
|
||||
return
|
||||
}
|
||||
parts := bytes.SplitN(data, utils.UnsafeStringToBytes(";"), 3)
|
||||
metadata := make(map[string]string)
|
||||
var payload []byte
|
||||
if len(parts) > 2 && len(parts[2]) > 0 {
|
||||
payload, err = base64.StdEncoding.DecodeString(utils.UnsafeBytesToString(parts[2]))
|
||||
if err != nil {
|
||||
err = fmt.Errorf("Received OSC %s packet from terminal with invalid base64 encoded payload", OSC_NUMBER)
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(parts) > 1 {
|
||||
for _, record := range bytes.Split(parts[1], utils.UnsafeStringToBytes(":")) {
|
||||
rp := bytes.SplitN(record, utils.UnsafeStringToBytes("="), 2)
|
||||
v := ""
|
||||
if len(rp) == 2 {
|
||||
v = string(rp[1])
|
||||
}
|
||||
metadata[string(rp[0])] = v
|
||||
}
|
||||
}
|
||||
if reading_available_mimes {
|
||||
switch metadata["status"] {
|
||||
case "DATA":
|
||||
available_mimes = strings.Split(utils.UnsafeBytesToString(payload), " ")
|
||||
case "OK":
|
||||
case "DONE":
|
||||
reading_available_mimes = false
|
||||
if len(available_mimes) == 0 {
|
||||
return fmt.Errorf("The clipboard is empty")
|
||||
}
|
||||
for _, o := range outputs {
|
||||
err = o.assign_mime_type(available_mimes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
requested_mimes[o.remote_mime_type] = o
|
||||
if o.remote_mime_type == "." {
|
||||
o.started = true
|
||||
o.add_data(utils.UnsafeStringToBytes(strings.Join(available_mimes, "\n")))
|
||||
o.all_data_received = true
|
||||
}
|
||||
}
|
||||
lp.QueueWriteString(encode(map[string]string{"type": "read"}, strings.Join(utils.Keys(requested_mimes), " ")))
|
||||
default:
|
||||
return fmt.Errorf("Failed to read list of available data types in the clipboard with error: %s", metadata["status"])
|
||||
}
|
||||
} else {
|
||||
switch metadata["status"] {
|
||||
case "DATA":
|
||||
current_mime := metadata["mime"]
|
||||
o := requested_mimes[current_mime]
|
||||
if o != nil {
|
||||
if getting_data_for != current_mime {
|
||||
if prev := requested_mimes[getting_data_for]; prev != nil && !prev.all_data_received {
|
||||
prev.all_data_received = true
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
prev.commit()
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
}
|
||||
getting_data_for = current_mime
|
||||
}
|
||||
if !o.all_data_received {
|
||||
o.add_data(payload)
|
||||
}
|
||||
}
|
||||
case "OK":
|
||||
case "DONE":
|
||||
if prev := requested_mimes[getting_data_for]; getting_data_for != "" && prev != nil && !prev.all_data_received {
|
||||
prev.all_data_received = true
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
prev.commit()
|
||||
wg.Done()
|
||||
}()
|
||||
getting_data_for = ""
|
||||
}
|
||||
lp.Quit(0)
|
||||
default:
|
||||
return fmt.Errorf("Failed to read data from the clipboard with error: %s", metadata["status"])
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
esc_count := 0
|
||||
lp.OnKeyEvent = func(event *loop.KeyEvent) error {
|
||||
if event.MatchesPressOrRepeat("ctrl+c") || event.MatchesPressOrRepeat("esc") {
|
||||
event.Handled = true
|
||||
esc_count++
|
||||
if esc_count < 2 {
|
||||
key := "Esc"
|
||||
if event.MatchesPressOrRepeat("ctrl+c") {
|
||||
key = "Ctrl+C"
|
||||
}
|
||||
lp.QueueWriteString(fmt.Sprintf("Waiting for response from terminal, press %s again to abort. This could cause garbage to be spewed to the screen.\r\n", key))
|
||||
} else {
|
||||
return fmt.Errorf("Aborted by user!")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
err = lp.Run()
|
||||
wg.Wait()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ds := lp.DeathSignalName()
|
||||
if ds != "" {
|
||||
fmt.Println("Killed by signal: ", ds)
|
||||
lp.KillIfSignalled()
|
||||
return
|
||||
}
|
||||
for _, o := range outputs {
|
||||
if o.err != nil {
|
||||
err = fmt.Errorf("Failed to get %s with error: %w", o.arg, o.err)
|
||||
return
|
||||
}
|
||||
if !o.started {
|
||||
err = fmt.Errorf("No data for %s with MIME type: %s", o.arg, o.mime_type)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
13
tools/cmd/clipboard/write.go
Normal file
13
tools/cmd/clipboard/write.go
Normal file
@ -0,0 +1,13 @@
|
||||
// License: GPLv3 Copyright: 2022, Kovid Goyal, <kovid at kovidgoyal.net>
|
||||
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
|
||||
func run_set_loop(opts *Options, args []string) (err error) {
|
||||
return fmt.Errorf("TODO: Implement me")
|
||||
}
|
||||
@ -73,3 +73,28 @@ func Min[T constraints.Ordered](a T, items ...T) (ans T) {
|
||||
}
|
||||
return ans
|
||||
}
|
||||
|
||||
func Index[T comparable](haystack []T, needle T) int {
|
||||
for i, x := range haystack {
|
||||
if x == needle {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func Contains[T comparable](haystack []T, needle T) bool {
|
||||
return Index(haystack, needle) > -1
|
||||
}
|
||||
|
||||
// Keys returns the keys of the map m.
|
||||
// The keys will be an indeterminate order.
|
||||
func Keys[M ~map[K]V, K comparable, V any](m M) []K {
|
||||
r := make([]K, len(m))
|
||||
i := 0
|
||||
for k := range m {
|
||||
r[i] = k
|
||||
i++
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user