main
Raw Download raw file
  1package input
  2
  3import (
  4	"context"
  5	"fmt"
  6	"log/slog"
  7	"os"
  8	"path/filepath"
  9	"sync"
 10	"time"
 11
 12	"github.com/fsnotify/fsnotify"
 13)
 14
 15// ClipboardSender can send clipboard data to a VNC server.
 16type ClipboardSender interface {
 17	SetClipboard(ctx context.Context, text string) error
 18}
 19
 20// ClipboardHandler is called when clipboard data is received from the server.
 21type ClipboardHandler func(text string) error
 22
 23// ClipboardManager handles clipboard synchronization between local files and VNC.
 24type ClipboardManager struct {
 25	sender  ClipboardSender
 26	handler ClipboardHandler
 27
 28	watchDir string
 29	sentDir  string
 30	saveDir  string
 31
 32	mu            sync.RWMutex
 33	lastReceived  string
 34	lastRecvTime  time.Time
 35	watcher       *fsnotify.Watcher
 36}
 37
 38// ClipboardConfig configures the clipboard manager.
 39type ClipboardConfig struct {
 40	// WatchDir is the directory to watch for outgoing clipboard files.
 41	// Files placed here will be sent to the VNC server.
 42	WatchDir string
 43
 44	// SaveDir is the directory to save incoming clipboard data.
 45	// If empty, incoming clipboard is not saved to files.
 46	SaveDir string
 47
 48	// Handler is called when clipboard data is received from the server.
 49	Handler ClipboardHandler
 50}
 51
 52// NewClipboardManager creates a new clipboard manager.
 53func NewClipboardManager(sender ClipboardSender, cfg *ClipboardConfig) (*ClipboardManager, error) {
 54	cm := &ClipboardManager{
 55		sender:  sender,
 56		handler: cfg.Handler,
 57	}
 58
 59	if cfg.WatchDir != "" {
 60		cm.watchDir = cfg.WatchDir
 61		cm.sentDir = filepath.Join(cfg.WatchDir, "sent")
 62
 63		// Create directories
 64		err := os.MkdirAll(cm.watchDir, 0755)
 65		if err != nil {
 66			return nil, fmt.Errorf("creating watch dir: %w", err)
 67		}
 68		err = os.MkdirAll(cm.sentDir, 0755)
 69		if err != nil {
 70			return nil, fmt.Errorf("creating sent dir: %w", err)
 71		}
 72	}
 73
 74	if cfg.SaveDir != "" {
 75		cm.saveDir = cfg.SaveDir
 76		err := os.MkdirAll(cm.saveDir, 0755)
 77		if err != nil {
 78			return nil, fmt.Errorf("creating save dir: %w", err)
 79		}
 80	}
 81
 82	return cm, nil
 83}
 84
 85// Start begins watching for clipboard files and processing them.
 86func (cm *ClipboardManager) Start(ctx context.Context) error {
 87	if cm.watchDir == "" {
 88		// No watch directory configured, just wait for context
 89		<-ctx.Done()
 90		return ctx.Err()
 91	}
 92
 93	watcher, err := fsnotify.NewWatcher()
 94	if err != nil {
 95		return fmt.Errorf("creating watcher: %w", err)
 96	}
 97	cm.watcher = watcher
 98	defer watcher.Close()
 99
100	err = watcher.Add(cm.watchDir)
101	if err != nil {
102		return fmt.Errorf("watching directory: %w", err)
103	}
104
105	slog.Info("watching clipboard directory",
106		slog.String("dir", cm.watchDir))
107
108	for {
109		select {
110		case <-ctx.Done():
111			return ctx.Err()
112
113		case event, ok := <-watcher.Events:
114			if !ok {
115				return nil
116			}
117
118			// Only process Create and Write events
119			if !event.Has(fsnotify.Create) && !event.Has(fsnotify.Write) {
120				continue
121			}
122
123			// Skip the sent/ subdirectory
124			if filepath.Dir(event.Name) != cm.watchDir {
125				continue
126			}
127
128			// Small delay to ensure file is fully written
129			time.Sleep(10 * time.Millisecond)
130
131			cm.processFile(ctx, event.Name)
132
133		case err, ok := <-watcher.Errors:
134			if !ok {
135				return nil
136			}
137			slog.Warn("watcher error", slog.String("error", err.Error()))
138		}
139	}
140}
141
142// processFile reads a file and sends its contents as clipboard data.
143func (cm *ClipboardManager) processFile(ctx context.Context, path string) {
144	info, err := os.Stat(path)
145	if err != nil || info.IsDir() {
146		return
147	}
148
149	data, err := os.ReadFile(path)
150	if err != nil {
151		slog.Warn("reading clipboard file",
152			slog.String("file", path),
153			slog.String("error", err.Error()))
154		return
155	}
156
157	slog.Info("sending clipboard from file",
158		slog.String("file", filepath.Base(path)),
159		slog.Int("len", len(data)))
160
161	err = cm.sender.SetClipboard(ctx, string(data))
162	if err != nil {
163		slog.Warn("sending clipboard", slog.String("error", err.Error()))
164		return
165	}
166
167	// Move to sent/ after successful send
168	sentPath := filepath.Join(cm.sentDir, filepath.Base(path))
169	err = os.Rename(path, sentPath)
170	if err != nil {
171		slog.Warn("moving sent file", slog.String("error", err.Error()))
172	}
173}
174
175// Send sends text to the VNC clipboard.
176func (cm *ClipboardManager) Send(ctx context.Context, text string) error {
177	return cm.sender.SetClipboard(ctx, text)
178}
179
180// OnReceive handles incoming clipboard data from the server.
181// This is typically called by the RFB client's clipboard handler.
182func (cm *ClipboardManager) OnReceive(text string) {
183	cm.mu.Lock()
184	cm.lastReceived = text
185	cm.lastRecvTime = time.Now()
186	cm.mu.Unlock()
187
188	// Save to file if configured
189	if cm.saveDir != "" {
190		filename := filepath.Join(cm.saveDir, fmt.Sprintf("%x.clip", time.Now().Unix()))
191		err := os.WriteFile(filename, []byte(text), 0644)
192		if err != nil {
193			slog.Warn("saving clipboard", slog.String("error", err.Error()))
194		} else {
195			slog.Debug("saved clipboard", slog.String("file", filename))
196		}
197	}
198
199	// Call handler if configured
200	if cm.handler != nil {
201		err := cm.handler(text)
202		if err != nil {
203			slog.Warn("clipboard handler error",
204				slog.String("error", err.Error()))
205		}
206	}
207}
208
209// GetLastReceived returns the last received clipboard data and timestamp.
210func (cm *ClipboardManager) GetLastReceived() (string, time.Time) {
211	cm.mu.RLock()
212	defer cm.mu.RUnlock()
213	return cm.lastReceived, cm.lastRecvTime
214}
215
216// Close stops watching and cleans up resources.
217func (cm *ClipboardManager) Close() error {
218	if cm.watcher != nil {
219		return cm.watcher.Close()
220	}
221	return nil
222}