// 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" "runtime" "strings" "time" "github.com/atotto/clipboard" ) // defaultLabel returns "{platform}-{transport}-{role}" so the server can // tell at a glance who's connecting. Transport is "sse" today; "rtc" once // WebRTC lands (v0.3 roadmap). func defaultLabel(role string) string { return fmt.Sprintf("%s-sse-%s", runtime.GOOS, role) } 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", "https://tether.pecord.io", "tether-server base URL") label := flag.String("label", "", "X-Tether-Client label (default: -sse-)") 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() if *label == "" { if *sendText != "" { *label = defaultLabel("sender") } else { *label = defaultLabel("listener") } } 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 }