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>
122 lines
3.2 KiB
Go
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
|
|
}
|