Skip to content

Commit 44780cf

Browse files
committed
implement host-container clipboard bridge for images
1 parent a5c0d31 commit 44780cf

11 files changed

Lines changed: 480 additions & 17 deletions

File tree

.gitignore

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,5 +36,4 @@ build/
3636
# Test artifacts
3737
test-results/
3838
.test-cache/
39-
/.gemini-clipboard
40-
/reference-only-mcp-cli-ent
39+
/gemini-cli-main

internal/agent/runner.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import (
55
"os"
66
"os/exec"
77
"strings"
8+
stdruntime "runtime"
89

10+
"github.com/EstebanForge/construct-cli/internal/clipboard"
911
"github.com/EstebanForge/construct-cli/internal/config"
1012
"github.com/EstebanForge/construct-cli/internal/env"
1113
"github.com/EstebanForge/construct-cli/internal/errors"
@@ -237,6 +239,20 @@ func runWithProviderEnv(args []string, cfg *config.Config, containerRuntime, con
237239
osEnv := os.Environ()
238240
osEnv = append(osEnv, "PWD="+cwd)
239241

242+
// Start Clipboard Server
243+
cbServer, err := clipboard.StartServer()
244+
if err != nil {
245+
if ui.CurrentLogLevel >= ui.LogLevelInfo {
246+
fmt.Printf("Warning: Failed to start clipboard server: %v\n", err)
247+
}
248+
} else {
249+
if ui.CurrentLogLevel >= ui.LogLevelDebug {
250+
fmt.Printf("Clipboard server running at %s\n", cbServer.URL)
251+
}
252+
osEnv = append(osEnv, "CONSTRUCT_CLIPBOARD_URL="+cbServer.URL)
253+
osEnv = append(osEnv, "CONSTRUCT_CLIPBOARD_TOKEN="+cbServer.Token)
254+
}
255+
240256
// Network configuration
241257
osEnv = network.InjectEnv(osEnv, cfg)
242258

@@ -271,6 +287,15 @@ func runWithProviderEnv(args []string, cfg *config.Config, containerRuntime, con
271287
if containerRuntime == "docker" {
272288
if _, err := exec.LookPath("docker-compose"); err == nil {
273289
runArgs := append(composeArgs, "run", "--rm")
290+
// Add host.docker.internal for Linux
291+
if stdruntime.GOOS == "linux" {
292+
runArgs = append(runArgs, "--add-host", "host.docker.internal:host-gateway")
293+
}
294+
// Inject clipboard env vars
295+
if cbServer != nil {
296+
runArgs = append(runArgs, "-e", "CONSTRUCT_CLIPBOARD_URL="+cbServer.URL)
297+
runArgs = append(runArgs, "-e", "CONSTRUCT_CLIPBOARD_TOKEN="+cbServer.Token)
298+
}
274299
// Inject provider env vars
275300
for _, envVar := range providerEnv {
276301
runArgs = append(runArgs, "-e", envVar)
@@ -282,6 +307,15 @@ func runWithProviderEnv(args []string, cfg *config.Config, containerRuntime, con
282307
runArgs := []string{"compose"}
283308
runArgs = append(runArgs, composeArgs...)
284309
runArgs = append(runArgs, "run", "--rm")
310+
// Add host.docker.internal for Linux
311+
if stdruntime.GOOS == "linux" {
312+
runArgs = append(runArgs, "--add-host", "host.docker.internal:host-gateway")
313+
}
314+
// Inject clipboard env vars
315+
if cbServer != nil {
316+
runArgs = append(runArgs, "-e", "CONSTRUCT_CLIPBOARD_URL="+cbServer.URL)
317+
runArgs = append(runArgs, "-e", "CONSTRUCT_CLIPBOARD_TOKEN="+cbServer.Token)
318+
}
285319
// Inject provider env vars
286320
for _, envVar := range providerEnv {
287321
runArgs = append(runArgs, "-e", envVar)
@@ -292,6 +326,15 @@ func runWithProviderEnv(args []string, cfg *config.Config, containerRuntime, con
292326
}
293327
} else if containerRuntime == "podman" {
294328
runArgs := append(composeArgs, "run", "--rm")
329+
// Add host.docker.internal for Linux
330+
if stdruntime.GOOS == "linux" {
331+
runArgs = append(runArgs, "--add-host", "host.docker.internal:host-gateway")
332+
}
333+
// Inject clipboard env vars
334+
if cbServer != nil {
335+
runArgs = append(runArgs, "-e", "CONSTRUCT_CLIPBOARD_URL="+cbServer.URL)
336+
runArgs = append(runArgs, "-e", "CONSTRUCT_CLIPBOARD_TOKEN="+cbServer.Token)
337+
}
295338
// Inject provider env vars
296339
for _, envVar := range providerEnv {
297340
runArgs = append(runArgs, "-e", envVar)
@@ -303,6 +346,15 @@ func runWithProviderEnv(args []string, cfg *config.Config, containerRuntime, con
303346
runArgs := []string{"compose"}
304347
runArgs = append(runArgs, composeArgs...)
305348
runArgs = append(runArgs, "run", "--rm")
349+
// Add host.docker.internal for Linux
350+
if stdruntime.GOOS == "linux" {
351+
runArgs = append(runArgs, "--add-host", "host.docker.internal:host-gateway")
352+
}
353+
// Inject clipboard env vars
354+
if cbServer != nil {
355+
runArgs = append(runArgs, "-e", "CONSTRUCT_CLIPBOARD_URL="+cbServer.URL)
356+
runArgs = append(runArgs, "-e", "CONSTRUCT_CLIPBOARD_TOKEN="+cbServer.Token)
357+
}
306358
// Inject provider env vars
307359
for _, envVar := range providerEnv {
308360
runArgs = append(runArgs, "-e", envVar)

internal/clipboard/clipboard.go

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
package clipboard
2+
3+
import (
4+
"bytes"
5+
"encoding/base64"
6+
"encoding/hex"
7+
"errors"
8+
"fmt"
9+
"os"
10+
"os/exec"
11+
"runtime"
12+
)
13+
14+
var ErrNoImage = errors.New("no image in clipboard")
15+
var ErrNoText = errors.New("no text in clipboard")
16+
17+
// GetText retrieves text data from the host clipboard
18+
func GetText() ([]byte, error) {
19+
switch runtime.GOOS {
20+
case "darwin":
21+
return getMacText()
22+
case "linux":
23+
return getLinuxText()
24+
case "windows":
25+
return getWindowsText()
26+
default:
27+
return nil, fmt.Errorf("unsupported OS: %s", runtime.GOOS)
28+
}
29+
}
30+
31+
// GetImage retrieves PNG data from the host clipboard using OS-specific tools
32+
func GetImage() ([]byte, error) {
33+
switch runtime.GOOS {
34+
case "darwin":
35+
return getMacImage()
36+
case "linux":
37+
return getLinuxImage()
38+
case "windows":
39+
return getWindowsImage()
40+
default:
41+
return nil, fmt.Errorf("unsupported OS: %s", runtime.GOOS)
42+
}
43+
}
44+
45+
func getMacImage() ([]byte, error) {
46+
// Request clipboard as PNG data. osascript will return it as a hex string
47+
// in the format: «data PNGf89504E47...»
48+
script := "get the clipboard as «class PNGf»"
49+
cmd := exec.Command("osascript", "-e", script)
50+
output, err := cmd.CombinedOutput()
51+
if err != nil {
52+
// If it fails, maybe no image
53+
return nil, ErrNoImage
54+
}
55+
56+
trimmed := bytes.TrimSpace(output)
57+
if len(trimmed) == 0 {
58+
return nil, ErrNoImage
59+
}
60+
61+
// Look for the hex start. PNG magic is 89504E47.
62+
// The output is usually «data PNGf89504E47...»
63+
startMarker := []byte("«data PNGf")
64+
endMarker := []byte("»")
65+
66+
startIdx := bytes.Index(trimmed, startMarker)
67+
if startIdx == -1 {
68+
return nil, ErrNoImage
69+
}
70+
startIdx += len(startMarker)
71+
72+
endIdx := bytes.LastIndex(trimmed, endMarker)
73+
if endIdx == -1 {
74+
endIdx = len(trimmed)
75+
}
76+
77+
hexData := trimmed[startIdx:endIdx]
78+
79+
// Decode hex
80+
data := make([]byte, hex.DecodedLen(len(hexData)))
81+
n, err := hex.Decode(data, hexData)
82+
if err != nil {
83+
return nil, fmt.Errorf("failed to decode clipboard hex data: %w", err)
84+
}
85+
86+
return data[:n], nil
87+
}
88+
89+
func getLinuxImage() ([]byte, error) {
90+
// Try wl-paste (Wayland) first, then xclip (X11)
91+
92+
// Check if we are in Wayland
93+
if os.Getenv("WAYLAND_DISPLAY") != "" {
94+
cmd := exec.Command("wl-paste", "-t", "image/png")
95+
data, err := cmd.Output()
96+
if err == nil && len(data) > 0 {
97+
return data, nil
98+
}
99+
// Fallthrough to xclip if wl-paste fails or not present
100+
}
101+
102+
// Try xclip
103+
cmd := exec.Command("xclip", "-selection", "clipboard", "-t", "image/png", "-o")
104+
data, err := cmd.Output()
105+
if err != nil {
106+
// Verify if xclip is installed
107+
if _, lookErr := exec.LookPath("xclip"); lookErr != nil {
108+
return nil, fmt.Errorf("xclip or wl-paste not found on host")
109+
}
110+
// xclip returns error if no target found (no image)
111+
return nil, ErrNoImage
112+
}
113+
114+
if len(data) == 0 {
115+
return nil, ErrNoImage
116+
}
117+
118+
return data, nil
119+
}
120+
121+
func getMacText() ([]byte, error) {
122+
cmd := exec.Command("pbpaste")
123+
return cmd.Output()
124+
}
125+
126+
func getLinuxText() ([]byte, error) {
127+
// Try wl-paste
128+
if os.Getenv("WAYLAND_DISPLAY") != "" {
129+
cmd := exec.Command("wl-paste", "--no-newline")
130+
data, err := cmd.Output()
131+
if err == nil {
132+
return data, nil
133+
}
134+
}
135+
// Try xclip
136+
cmd := exec.Command("xclip", "-selection", "clipboard", "-o")
137+
return cmd.Output()
138+
}
139+
140+
func getWindowsText() ([]byte, error) {
141+
cmd := exec.Command("powershell", "-NoProfile", "-Command", "Get-Clipboard -Raw")
142+
return cmd.Output()
143+
}
144+
145+
func getWindowsImage() ([]byte, error) {
146+
// Use PowerShell to get clipboard image
147+
// We use a script to output base64 then decode
148+
149+
psScript := `
150+
Add-Type -AssemblyName System.Windows.Forms
151+
$img = [System.Windows.Forms.Clipboard]::GetImage()
152+
if ($img -eq $null) { exit 1 }
153+
$ms = New-Object System.IO.MemoryStream
154+
$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
155+
$base64 = [Convert]::ToBase64String($ms.ToArray())
156+
Write-Output $base64
157+
`
158+
159+
// Note: System.Windows.Forms requires STA mode (-Sta)
160+
cmd := exec.Command("powershell", "-NoProfile", "-Sta", "-Command", psScript)
161+
output, err := cmd.Output()
162+
if err != nil {
163+
return nil, ErrNoImage // Assume no image or failure
164+
}
165+
166+
base64Str := string(bytes.TrimSpace(output))
167+
if base64Str == "" {
168+
return nil, ErrNoImage
169+
}
170+
171+
data, err := base64.StdEncoding.DecodeString(base64Str)
172+
if err != nil {
173+
return nil, fmt.Errorf("failed to decode base64 image data: %w", err)
174+
}
175+
176+
return data, nil
177+
}

internal/clipboard/server.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package clipboard
2+
3+
import (
4+
"crypto/rand"
5+
"encoding/hex"
6+
"fmt"
7+
"net"
8+
"net/http"
9+
"os"
10+
)
11+
12+
// Server represents the clipboard server
13+
type Server struct {
14+
Port int
15+
Token string
16+
URL string
17+
listener net.Listener
18+
}
19+
20+
// StartServer starts the clipboard server on a random port
21+
func StartServer() (*Server, error) {
22+
// Generate random token
23+
24+
tokenBytes := make([]byte, 32)
25+
if _, err := rand.Read(tokenBytes); err != nil {
26+
return nil, fmt.Errorf("failed to generate token: %w", err)
27+
}
28+
token := hex.EncodeToString(tokenBytes)
29+
30+
// Listen on random port (all interfaces to allow container access)
31+
listener, err := net.Listen("tcp", "0.0.0.0:0")
32+
if err != nil {
33+
return nil, fmt.Errorf("failed to start listener: %w", err)
34+
}
35+
36+
port := listener.Addr().(*net.TCPAddr).Port
37+
38+
// Determine URL host (host.docker.internal is standard, but we return the port)
39+
// The client inside container will use host.docker.internal
40+
url := fmt.Sprintf("http://host.docker.internal:%d", port)
41+
42+
server := &Server{
43+
Port: port,
44+
Token: token,
45+
URL: url,
46+
listener: listener,
47+
}
48+
49+
// Start serving in background
50+
go server.serve()
51+
52+
return server, nil
53+
}
54+
55+
func (s *Server) serve() {
56+
mux := http.NewServeMux()
57+
mux.HandleFunc("/paste", s.handlePaste)
58+
59+
// We use the existing listener
60+
http.Serve(s.listener, mux)
61+
}
62+
63+
func (s *Server) handlePaste(w http.ResponseWriter, r *http.Request) {
64+
fmt.Fprintf(os.Stderr, "[Clipboard Server] Received paste request from %s\n", r.RemoteAddr)
65+
66+
// Verify token
67+
if r.Header.Get("X-Construct-Clip-Token") != s.Token {
68+
fmt.Fprintf(os.Stderr, "[Clipboard Server] Unauthorized request (invalid token)\n")
69+
http.Error(w, "Unauthorized", http.StatusUnauthorized)
70+
return
71+
}
72+
73+
contentType := r.URL.Query().Get("type")
74+
75+
if contentType == "text/plain" || contentType == "" {
76+
data, err := GetText()
77+
if err != nil {
78+
fmt.Fprintf(os.Stderr, "[Clipboard Server] GetText error: %v\n", err)
79+
http.Error(w, err.Error(), http.StatusInternalServerError)
80+
return
81+
}
82+
fmt.Fprintf(os.Stderr, "[Clipboard Server] Serving %d bytes of text data\n", len(data))
83+
w.Header().Set("Content-Type", "text/plain")
84+
w.Write(data)
85+
return
86+
}
87+
88+
// Get image from host clipboard
89+
data, err := GetImage()
90+
if err != nil {
91+
fmt.Fprintf(os.Stderr, "[Clipboard Server] GetImage error: %v\n", err)
92+
if err == ErrNoImage {
93+
http.Error(w, "No image in clipboard", http.StatusNotFound)
94+
} else {
95+
http.Error(w, err.Error(), http.StatusInternalServerError)
96+
}
97+
return
98+
}
99+
100+
fmt.Fprintf(os.Stderr, "[Clipboard Server] Serving %d bytes of image data\n", len(data))
101+
w.Header().Set("Content-Type", "image/png")
102+
w.Write(data)
103+
}

0 commit comments

Comments
 (0)