Files
tether/client/main.go
Claude Opus 4.7 fa52bf2693 client: actually write incoming messages to the OS clipboard
The MVP only printed to stdout. Now the listener calls
clipboard.WriteAll on every received message, except when the message
originated from itself (to avoid clobbering local edits with our own
prior send).

Adds:
- github.com/atotto/clipboard (cross-platform: Win/macOS/Linux)
- -no-clipboard flag for stdout-only mode
- "→ clipboard updated" trace line so the user can confirm the write

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 00:19:35 -05:00

122 lines
3.2 KiB
Go

// tether-client: connects to a tether-server, writes incoming messages
// to the OS clipboard and prints them to stdout.
package main
import (
"bufio"
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/atotto/clipboard"
)
var (
noClipboard bool
myLabel string
)
type Message struct {
Text string `json:"text"`
Source string `json:"source,omitempty"`
TS int64 `json:"ts"`
}
func main() {
server := flag.String("server", "http://localhost:8765", "tether-server base URL")
label := flag.String("label", "linux-client", "X-Tether-Client label")
sendText := flag.String("send", "", "send this text and exit (otherwise listen)")
flag.BoolVar(&noClipboard, "no-clipboard", false, "don't write incoming messages to the OS clipboard")
flag.Parse()
myLabel = *label
if *sendText != "" {
send(*server, *label, *sendText)
return
}
if !noClipboard && clipboard.Unsupported {
fmt.Fprintln(os.Stderr, "tether-client: OS clipboard unsupported on this platform; falling back to stdout-only")
noClipboard = true
}
for {
if err := listen(*server, *label); err != nil {
log.Printf("stream error: %v — reconnecting in 3s", err)
time.Sleep(3 * time.Second)
}
}
}
func send(server, label, text string) {
body, _ := json.Marshal(Message{Text: text, Source: label})
req, _ := http.NewRequest("POST", server+"/api/send", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Tether-Source", label)
r, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatalf("send: %v", err)
}
defer r.Body.Close()
if r.StatusCode >= 300 {
log.Fatalf("send: HTTP %d", r.StatusCode)
}
fmt.Println("sent.")
}
func listen(server, label string) error {
req, _ := http.NewRequest("GET", server+"/api/stream", nil)
req.Header.Set("X-Tether-Client", label)
r, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer r.Body.Close()
if r.StatusCode != 200 {
return fmt.Errorf("HTTP %d", r.StatusCode)
}
fmt.Fprintf(os.Stderr, "tether-client: connected to %s as %q\n", server, label)
sc := bufio.NewScanner(r.Body)
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
var ev, data string
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event: "):
ev = strings.TrimPrefix(line, "event: ")
case strings.HasPrefix(line, "data: "):
data = strings.TrimPrefix(line, "data: ")
case line == "":
if ev == "clipboard" && data != "" {
var m Message
if err := json.Unmarshal([]byte(data), &m); err == nil {
ts := time.UnixMilli(m.TS).Format("15:04:05")
fmt.Printf("\n────── %s from %s ──────\n%s\n", ts, m.Source, m.Text)
// Don't echo our own message back into our own clipboard
if !noClipboard && m.Source != myLabel {
if err := clipboard.WriteAll(m.Text); err != nil {
fmt.Fprintf(os.Stderr, " ! clipboard write error: %v\n", err)
} else {
fmt.Fprintln(os.Stderr, " → clipboard updated")
}
}
}
}
ev, data = "", ""
case strings.HasPrefix(line, ": "):
// keepalive — ignore
}
}
if err := sc.Err(); err != nil && err != io.EOF {
return err
}
return nil
}