main
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}