Skip to content

Commit 48a8fe5

Browse files
authored
Add copy/paste support (#1520)
* 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
1 parent b6f9d92 commit 48a8fe5

File tree

12 files changed

+242
-14
lines changed

12 files changed

+242
-14
lines changed

cmd/minimega/external.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ var (
2525
MIN_OVS = []int{1, 11}
2626
// MIN_KERNEL for Overlayfs
2727
MIN_KERNEL = []int{3, 18}
28+
29+
// feature requirements
30+
MIN_QEMU_COPY_PASTE = []int{6, 1}
2831
)
2932

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

261+
// checks that qemu has the chardev `required`
262+
func checkQemuChardev(required string) error {
263+
out, err := processWrapper("kvm", "-chardev", "help")
264+
if err != nil {
265+
return fmt.Errorf("check qemu chardev failed: %v", err)
266+
}
267+
268+
fields := strings.Split(out, "\n")
269+
for _, f := range fields {
270+
if strings.TrimSpace(f) == required {
271+
return nil
272+
}
273+
}
274+
return fmt.Errorf("qemu does not have required chardev: %v", required)
275+
}
276+
258277
// lsModule returns true if the specified module is in the `lsmod` output
259278
func lsModule(s string) bool {
260279
log.Info("checking for kernel module: %v", s)

cmd/minimega/kvm.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,12 @@ type KVMConfig struct {
189189
// socket at the path provided
190190
TpmSocketPath string
191191

192+
// Enables bidirectional copy paste instead of basic pasting into VM.
193+
// Requires QEMU 6.1+ compiled with qemu-vdagent chardev and for spice-vdagent to be installed on VM.
194+
//
195+
// Default: false
196+
BidirectionalCopyPaste bool
197+
192198
// Add additional arguments to be passed to the QEMU instance. For example:
193199
//
194200
// vm config qemu-append -serial tcp:localhost:4001
@@ -546,6 +552,7 @@ func (vm *KVMConfig) String() string {
546552
fmt.Fprintf(w, "Sockets:\t%v\n", vm.Sockets)
547553
fmt.Fprintf(w, "VGA:\t%v\n", vm.Vga)
548554
fmt.Fprintf(w, "Usb Use XHCI:\t%v\n", vm.UsbUseXHCI)
555+
fmt.Fprintf(w, "Bidirectional Copy Paste:\t%v\n", vm.BidirectionalCopyPaste)
549556
fmt.Fprintf(w, "TPM Socket: \t%v\n", vm.TpmSocketPath)
550557
w.Flush()
551558
fmt.Fprintln(&o)
@@ -772,6 +779,14 @@ func (vm *KvmVM) connectVNC() error {
772779
for {
773780
msg, err := vnc.ReadClientMessage(tee)
774781
if err == nil {
782+
// for cut text, send text immediately as string if not bi-directional
783+
if cut, ok := msg.(*vnc.ClientCutText); ok && !vm.BidirectionalCopyPaste {
784+
log.Info("sending text for ClientCutText: %s", cut.Text)
785+
err = ns.Player.PlaybackString(vm.Name, vm.vncShim.Addr().String(), string(cut.Text))
786+
if err != nil {
787+
log.Warnln(err)
788+
}
789+
}
775790
ns.Recorder.Route(vm.GetName(), msg)
776791
continue
777792
}
@@ -926,6 +941,17 @@ func (vm *KvmVM) launch() error {
926941
var sErr bytes.Buffer
927942

928943
vmConfig := VMConfig{BaseConfig: vm.BaseConfig, KVMConfig: vm.KVMConfig}
944+
945+
// if using bidirectionalCopyPaste, error out if dependencies aren't met
946+
if vmConfig.BidirectionalCopyPaste {
947+
if err := checkVersion("qemu", MIN_QEMU_COPY_PASTE, qemuVersion); err != nil {
948+
return fmt.Errorf("bidirectional-copy-paste not supported. Please disable: %v", err)
949+
}
950+
if err := checkQemuChardev("qemu-vdagent"); err != nil {
951+
return fmt.Errorf("bidirectional-copy-paste not supported. Please disable: %v", err)
952+
}
953+
}
954+
929955
args := vmConfig.qemuArgs(vm.ID, vm.instancePath)
930956
args = vmConfig.applyQemuOverrides(args)
931957
log.Debug("final qemu args: %#v", args)
@@ -1475,6 +1501,15 @@ func (vm VMConfig) qemuArgs(id int, vmPath string) []string {
14751501
args = append(args, fmt.Sprintf("virtserialport,bus=virtio-serial%v.0,chardev=charvserialCC,id=charvserialCC,name=cc", virtioPort))
14761502
}
14771503

1504+
if vm.BidirectionalCopyPaste {
1505+
addVirtioDevice()
1506+
1507+
args = append(args, "-chardev")
1508+
args = append(args, "qemu-vdagent,id=vdagent,clipboard=on")
1509+
args = append(args, "-device")
1510+
args = append(args, fmt.Sprintf("virtserialport,bus=virtio-serial%v.0,chardev=vdagent,name=com.redhat.spice.0", virtioPort))
1511+
}
1512+
14781513
if vm.VirtioPorts != "" {
14791514
names := []string{}
14801515

cmd/minimega/vmconfiger_cli.go

Lines changed: 33 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cmd/minimega/vnc_cli.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"errors"
99
"fmt"
1010
"path/filepath"
11+
"strings"
1112
"sync"
1213

1314
"github.com/sandia-minimega/minimega/v2/pkg/minicli"
@@ -64,6 +65,7 @@ below.
6465
#: This is an example of a vnc playback comment`,
6566
Patterns: []string{
6667
"vnc <play,> <vm target> <filename>",
68+
"vnc <type,> <vm target> <str>...",
6769
"vnc <stop,> <vm target>",
6870
"vnc <pause,> <vm target>",
6971
"vnc <continue,> <vm target>",
@@ -126,6 +128,8 @@ func cliVNCPlay(ns *Namespace, c *minicli.Command, resp *minicli.Response) error
126128
switch {
127129
case c.BoolArgs["play"]:
128130
return true, ns.Player.Playback(id, rhost, fname)
131+
case c.BoolArgs["type"]:
132+
return true, ns.Player.PlaybackString(id, rhost, strings.Join(c.ListArgs["str"], " "))
129133
case c.BoolArgs["stop"]:
130134
return true, ns.Player.Stop(id)
131135
case c.BoolArgs["inject"]:

internal/vnc/decode.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ func ReadClientMessage(r io.Reader) (interface{}, error) {
5454
msg = msg2
5555
case TypeClientCutText:
5656
msg2 := &ClientCutText{_ClientCutText: *msg.(*_ClientCutText)}
57+
if msg2.Length < 0 {
58+
msg2.Length = -msg2.Length
59+
}
5760
msg2.Text = make([]uint8, msg2.Length)
5861

5962
err = binary.Read(r, binary.BigEndian, &msg2.Text)

internal/vnc/keysym.go

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44

55
package vnc
66

7-
import "fmt"
7+
import (
8+
"fmt"
9+
"unicode"
10+
)
811

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

3437
return uint32(0), fmt.Errorf("unknown key: `%s`", s)
3538
}
39+
40+
func asciiCharToKeysymString(c rune) (string, error) {
41+
// ascii 0x20 - 0x7E map directly to keysym values.
42+
// manually shift cases for tab, nl, and cr
43+
if c == 0x9 || c == 0xa || c == 0xd {
44+
c += 0xff00
45+
} else if c >= unicode.MaxASCII {
46+
return "", fmt.Errorf("unknown non-ascii character: %U %c", c, c)
47+
}
48+
keysym, err := xKeysymToString(uint32(c))
49+
if err != nil {
50+
return "uint32(0)", fmt.Errorf("character has no keysym mapping: %c", c)
51+
}
52+
return keysym, nil
53+
}
54+
55+
func requiresShift(s string) bool {
56+
_, ok := shiftedKeysyms[s]
57+
return ok
58+
}

internal/vnc/playback.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,10 +209,12 @@ func (p *playback) Stop() error {
209209
defer p.Unlock()
210210

211211
if p.closed {
212-
return errors.New("playback has already stopped")
212+
log.Info("playback has already stopped for %v", p.ID)
213+
return nil
213214
}
214215

215216
close(p.signal)
217+
close(p.done)
216218
p.closed = true
217219
log.Info("Finished playback on %v", p.ID)
218220

internal/vnc/player.go

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package vnc
66

77
import (
88
"fmt"
9+
"os"
910
"sync"
1011

1112
log "github.com/sandia-minimega/minimega/v2/pkg/minilog"
@@ -99,29 +100,71 @@ func (p *Player) reap() {
99100
// Creates a new VNC connection, the initial playback reader, and starts the
100101
// vnc playback
101102
func (p *Player) Playback(id, rhost, filename string) error {
103+
_, err := p.playback(id, rhost, filename)
104+
return err
105+
}
106+
107+
func (p *Player) PlaybackString(id, rhost, str string) error {
108+
if len(str) == 0 {
109+
return nil
110+
}
111+
112+
f, err := os.CreateTemp("", "mm_vnc_")
113+
if err != nil {
114+
return err
115+
}
116+
117+
for _, char := range str {
118+
keysym, err := asciiCharToKeysymString(char)
119+
if err != nil {
120+
return err
121+
}
122+
shift := requiresShift(keysym)
123+
if shift {
124+
f.WriteString("0:KeyEvent,true,Shift_L\n")
125+
}
126+
f.WriteString(fmt.Sprintf("0:KeyEvent,true,%s\n", keysym))
127+
f.WriteString(fmt.Sprintf("0:KeyEvent,false,%s\n", keysym))
128+
if shift {
129+
f.WriteString("0:KeyEvent,false,Shift_L\n")
130+
}
131+
}
132+
if err := f.Close(); err != nil {
133+
return err
134+
}
135+
136+
pb, err := p.playback(id, rhost, f.Name())
137+
138+
// remove file when playback is done
139+
go func() {
140+
<-pb.done
141+
if err := os.Remove(f.Name()); err != nil {
142+
log.Warn("Failed to delete temp file %s used for playback", f.Name())
143+
}
144+
}()
145+
146+
return err
147+
}
148+
149+
func (p *Player) playback(id, rhost, filename string) (*playback, error) {
102150
p.mu.Lock()
103151
defer p.mu.Unlock()
104-
105152
// clear out any old playbacks
106153
p.reap()
107154

108-
return p.playback(id, rhost, filename)
109-
}
110-
111-
func (p *Player) playback(id, rhost, filename string) error {
112155
// Is this playback already running?
113156
if _, ok := p.m[id]; ok {
114-
return fmt.Errorf("kb playback %v already playing", id)
157+
return nil, fmt.Errorf("kb playback %v already playing", id)
115158
}
116159

117160
pb, err := newPlayback(id, rhost)
118161
if err != nil {
119-
return err
162+
return nil, err
120163
}
121164

122165
p.m[pb.ID] = pb
123166

124-
return pb.Start(filename)
167+
return pb, pb.Start(filename)
125168
}
126169

127170
func (p *Player) Inject(id, rhost, s string) error {
@@ -157,7 +200,8 @@ func (p *Player) Inject(id, rhost, s string) error {
157200
case *LoadFileEvent:
158201
// This is an injected LoadFile event without a running playback. This is
159202
// equivalent to starting a new vnc playback.
160-
return p.playback(id, rhost, e.File)
203+
_, err := p.playback(id, rhost, e.File)
204+
return err
161205
case *WaitForItEvent:
162206
return fmt.Errorf("unhandled inject event for non-running playback: %T", e)
163207
default:

internal/vnc/protocol.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ type PointerEvent struct {
8787

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

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

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

191191
if err := writeMessage(w, TypeClientCutText, m._ClientCutText); err != nil {
192192
return err

0 commit comments

Comments
 (0)