Skip to content

Commit

Permalink
Add copy/paste support (#1520)
Browse files Browse the repository at this point in the history
* add `vnc type` command to send string
* properly shift keys for `vnc type`
* send ClientCutText contents immediately
* add bidirectional copy/paste flag
* fix requirements in help for bi-directional copy/paste
* disable extended clipboard support in noVNC
* vnc: handle negative length for ClientCutText
* print playback errors on paste; handle special characters
* error out on launch if bidirectional-copy-paste set and requirements not met
  • Loading branch information
jacdavi authored Nov 22, 2023
1 parent b6f9d92 commit 48a8fe5
Show file tree
Hide file tree
Showing 12 changed files with 242 additions and 14 deletions.
19 changes: 19 additions & 0 deletions cmd/minimega/external.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ var (
MIN_OVS = []int{1, 11}
// MIN_KERNEL for Overlayfs
MIN_KERNEL = []int{3, 18}

// feature requirements
MIN_QEMU_COPY_PASTE = []int{6, 1}
)

// externalDependencies contains all the external programs that minimega
Expand Down Expand Up @@ -255,6 +258,22 @@ func checkVersion(name string, min []int, versionFn func() ([]int, error)) error
return nil
}

// checks that qemu has the chardev `required`
func checkQemuChardev(required string) error {
out, err := processWrapper("kvm", "-chardev", "help")
if err != nil {
return fmt.Errorf("check qemu chardev failed: %v", err)
}

fields := strings.Split(out, "\n")
for _, f := range fields {
if strings.TrimSpace(f) == required {
return nil
}
}
return fmt.Errorf("qemu does not have required chardev: %v", required)
}

// lsModule returns true if the specified module is in the `lsmod` output
func lsModule(s string) bool {
log.Info("checking for kernel module: %v", s)
Expand Down
35 changes: 35 additions & 0 deletions cmd/minimega/kvm.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,12 @@ type KVMConfig struct {
// socket at the path provided
TpmSocketPath string

// Enables bidirectional copy paste instead of basic pasting into VM.
// Requires QEMU 6.1+ compiled with qemu-vdagent chardev and for spice-vdagent to be installed on VM.
//
// Default: false
BidirectionalCopyPaste bool

// Add additional arguments to be passed to the QEMU instance. For example:
//
// vm config qemu-append -serial tcp:localhost:4001
Expand Down Expand Up @@ -546,6 +552,7 @@ func (vm *KVMConfig) String() string {
fmt.Fprintf(w, "Sockets:\t%v\n", vm.Sockets)
fmt.Fprintf(w, "VGA:\t%v\n", vm.Vga)
fmt.Fprintf(w, "Usb Use XHCI:\t%v\n", vm.UsbUseXHCI)
fmt.Fprintf(w, "Bidirectional Copy Paste:\t%v\n", vm.BidirectionalCopyPaste)
fmt.Fprintf(w, "TPM Socket: \t%v\n", vm.TpmSocketPath)
w.Flush()
fmt.Fprintln(&o)
Expand Down Expand Up @@ -772,6 +779,14 @@ func (vm *KvmVM) connectVNC() error {
for {
msg, err := vnc.ReadClientMessage(tee)
if err == nil {
// for cut text, send text immediately as string if not bi-directional
if cut, ok := msg.(*vnc.ClientCutText); ok && !vm.BidirectionalCopyPaste {
log.Info("sending text for ClientCutText: %s", cut.Text)
err = ns.Player.PlaybackString(vm.Name, vm.vncShim.Addr().String(), string(cut.Text))
if err != nil {
log.Warnln(err)
}
}
ns.Recorder.Route(vm.GetName(), msg)
continue
}
Expand Down Expand Up @@ -926,6 +941,17 @@ func (vm *KvmVM) launch() error {
var sErr bytes.Buffer

vmConfig := VMConfig{BaseConfig: vm.BaseConfig, KVMConfig: vm.KVMConfig}

// if using bidirectionalCopyPaste, error out if dependencies aren't met
if vmConfig.BidirectionalCopyPaste {
if err := checkVersion("qemu", MIN_QEMU_COPY_PASTE, qemuVersion); err != nil {
return fmt.Errorf("bidirectional-copy-paste not supported. Please disable: %v", err)
}
if err := checkQemuChardev("qemu-vdagent"); err != nil {
return fmt.Errorf("bidirectional-copy-paste not supported. Please disable: %v", err)
}
}

args := vmConfig.qemuArgs(vm.ID, vm.instancePath)
args = vmConfig.applyQemuOverrides(args)
log.Debug("final qemu args: %#v", args)
Expand Down Expand Up @@ -1475,6 +1501,15 @@ func (vm VMConfig) qemuArgs(id int, vmPath string) []string {
args = append(args, fmt.Sprintf("virtserialport,bus=virtio-serial%v.0,chardev=charvserialCC,id=charvserialCC,name=cc", virtioPort))
}

if vm.BidirectionalCopyPaste {
addVirtioDevice()

args = append(args, "-chardev")
args = append(args, "qemu-vdagent,id=vdagent,clipboard=on")
args = append(args, "-device")
args = append(args, fmt.Sprintf("virtserialport,bus=virtio-serial%v.0,chardev=vdagent,name=com.redhat.spice.0", virtioPort))
}

if vm.VirtioPorts != "" {
names := []string{}

Expand Down
33 changes: 33 additions & 0 deletions cmd/minimega/vmconfiger_cli.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions cmd/minimega/vnc_cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"path/filepath"
"strings"
"sync"

"github.com/sandia-minimega/minimega/v2/pkg/minicli"
Expand Down Expand Up @@ -64,6 +65,7 @@ below.
#: This is an example of a vnc playback comment`,
Patterns: []string{
"vnc <play,> <vm target> <filename>",
"vnc <type,> <vm target> <str>...",
"vnc <stop,> <vm target>",
"vnc <pause,> <vm target>",
"vnc <continue,> <vm target>",
Expand Down Expand Up @@ -126,6 +128,8 @@ func cliVNCPlay(ns *Namespace, c *minicli.Command, resp *minicli.Response) error
switch {
case c.BoolArgs["play"]:
return true, ns.Player.Playback(id, rhost, fname)
case c.BoolArgs["type"]:
return true, ns.Player.PlaybackString(id, rhost, strings.Join(c.ListArgs["str"], " "))
case c.BoolArgs["stop"]:
return true, ns.Player.Stop(id)
case c.BoolArgs["inject"]:
Expand Down
3 changes: 3 additions & 0 deletions internal/vnc/decode.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ func ReadClientMessage(r io.Reader) (interface{}, error) {
msg = msg2
case TypeClientCutText:
msg2 := &ClientCutText{_ClientCutText: *msg.(*_ClientCutText)}
if msg2.Length < 0 {
msg2.Length = -msg2.Length
}
msg2.Text = make([]uint8, msg2.Length)

err = binary.Read(r, binary.BigEndian, &msg2.Text)
Expand Down
25 changes: 24 additions & 1 deletion internal/vnc/keysym.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@

package vnc

import "fmt"
import (
"fmt"
"unicode"
)

// Inverse of keysym.
var keysymInverse = map[uint32]string{}
Expand Down Expand Up @@ -33,3 +36,23 @@ func xStringToKeysym(s string) (uint32, error) {

return uint32(0), fmt.Errorf("unknown key: `%s`", s)
}

func asciiCharToKeysymString(c rune) (string, error) {
// ascii 0x20 - 0x7E map directly to keysym values.
// manually shift cases for tab, nl, and cr
if c == 0x9 || c == 0xa || c == 0xd {
c += 0xff00
} else if c >= unicode.MaxASCII {
return "", fmt.Errorf("unknown non-ascii character: %U %c", c, c)
}
keysym, err := xKeysymToString(uint32(c))
if err != nil {
return "uint32(0)", fmt.Errorf("character has no keysym mapping: %c", c)
}
return keysym, nil
}

func requiresShift(s string) bool {
_, ok := shiftedKeysyms[s]
return ok
}
4 changes: 3 additions & 1 deletion internal/vnc/playback.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,10 +209,12 @@ func (p *playback) Stop() error {
defer p.Unlock()

if p.closed {
return errors.New("playback has already stopped")
log.Info("playback has already stopped for %v", p.ID)
return nil
}

close(p.signal)
close(p.done)
p.closed = true
log.Info("Finished playback on %v", p.ID)

Expand Down
62 changes: 53 additions & 9 deletions internal/vnc/player.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package vnc

import (
"fmt"
"os"
"sync"

log "github.com/sandia-minimega/minimega/v2/pkg/minilog"
Expand Down Expand Up @@ -99,29 +100,71 @@ func (p *Player) reap() {
// Creates a new VNC connection, the initial playback reader, and starts the
// vnc playback
func (p *Player) Playback(id, rhost, filename string) error {
_, err := p.playback(id, rhost, filename)
return err
}

func (p *Player) PlaybackString(id, rhost, str string) error {
if len(str) == 0 {
return nil
}

f, err := os.CreateTemp("", "mm_vnc_")
if err != nil {
return err
}

for _, char := range str {
keysym, err := asciiCharToKeysymString(char)
if err != nil {
return err
}
shift := requiresShift(keysym)
if shift {
f.WriteString("0:KeyEvent,true,Shift_L\n")
}
f.WriteString(fmt.Sprintf("0:KeyEvent,true,%s\n", keysym))
f.WriteString(fmt.Sprintf("0:KeyEvent,false,%s\n", keysym))
if shift {
f.WriteString("0:KeyEvent,false,Shift_L\n")
}
}
if err := f.Close(); err != nil {
return err
}

pb, err := p.playback(id, rhost, f.Name())

// remove file when playback is done
go func() {
<-pb.done
if err := os.Remove(f.Name()); err != nil {
log.Warn("Failed to delete temp file %s used for playback", f.Name())
}
}()

return err
}

func (p *Player) playback(id, rhost, filename string) (*playback, error) {
p.mu.Lock()
defer p.mu.Unlock()

// clear out any old playbacks
p.reap()

return p.playback(id, rhost, filename)
}

func (p *Player) playback(id, rhost, filename string) error {
// Is this playback already running?
if _, ok := p.m[id]; ok {
return fmt.Errorf("kb playback %v already playing", id)
return nil, fmt.Errorf("kb playback %v already playing", id)
}

pb, err := newPlayback(id, rhost)
if err != nil {
return err
return nil, err
}

p.m[pb.ID] = pb

return pb.Start(filename)
return pb, pb.Start(filename)
}

func (p *Player) Inject(id, rhost, s string) error {
Expand Down Expand Up @@ -157,7 +200,8 @@ func (p *Player) Inject(id, rhost, s string) error {
case *LoadFileEvent:
// This is an injected LoadFile event without a running playback. This is
// equivalent to starting a new vnc playback.
return p.playback(id, rhost, e.File)
_, err := p.playback(id, rhost, e.File)
return err
case *WaitForItEvent:
return fmt.Errorf("unhandled inject event for non-running playback: %T", e)
default:
Expand Down
4 changes: 2 additions & 2 deletions internal/vnc/protocol.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ type PointerEvent struct {

type _ClientCutText struct {
_ [3]byte // Padding
Length uint32 // Length of Text
Length int32 // Length of Text. Signed for extended pseudo-encoding
}

// See RFC 6143 Section 7.5.6
Expand Down Expand Up @@ -186,7 +186,7 @@ func (m *PointerEvent) Write(w io.Writer) error {

func (m *ClientCutText) Write(w io.Writer) error {
// Ensure length is set correctly
m.Length = uint32(len(m.Text))
m.Length = int32(len(m.Text))

if err := writeMessage(w, TypeClientCutText, m._ClientCutText); err != nil {
return err
Expand Down
Loading

0 comments on commit 48a8fe5

Please sign in to comment.