Both Go peer and browser now broadcast {type:"presence", from, role}
every 10s on the bus. When either side sees a presence from someone
they don't yet have a RTCPeerConnection to, they initiate a new one
targeted at that specific peerID via the new "to" field on signal
messages. Each side keeps a map<peerID, RTCPeerConnection> instead of
the v0.3 single-connection model.
This means:
- N browsers can pair with M peers (true mesh)
- New tabs auto-discover existing peers via their next 10s chirp
- Restarts and network blips recover within 10s instead of needing
a manual browser refresh
- 45s lastSeen timeout sweeps disconnected peers and tears down their
PeerConnection
The browser UI now shows a row of peer chips that flip green when their
DataChannel opens. The pill shows "rtc" if *any* peer is open, else
"negotiating" if any are in progress, else "sse".
Go side regenerates a random peerID per process start (was static).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
428 lines
11 KiB
Go
428 lines
11 KiB
Go
// tether-client v0.4: SSE listener with optional WebRTC mesh peer.
|
|
//
|
|
// Default flow (SSE only): subscribe to /api/stream, write incoming
|
|
// clipboard messages to the OS clipboard. Works on Win/Linux/macOS.
|
|
//
|
|
// With -rtc: become a WebRTC peer that maintains one RTCPeerConnection
|
|
// per active browser (mesh, not star). Symmetric "presence" chirps on
|
|
// the signaling bus let participants discover each other and
|
|
// auto-upgrade SSE → direct DataChannel. After pairing, clipboard text
|
|
// flows direct peer-to-peer with DTLS encryption.
|
|
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/atotto/clipboard"
|
|
"github.com/pion/webrtc/v4"
|
|
)
|
|
|
|
// peerID — each client instance gets a stable random id for the bus.
|
|
var peerID = "tether-client-" + randomID(6)
|
|
|
|
func randomID(n int) string {
|
|
b := make([]byte, n)
|
|
_, _ = rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
func defaultLabel(role string) string {
|
|
return fmt.Sprintf("%s-%s-%s", runtime.GOOS, "sse", role)
|
|
}
|
|
|
|
// Message envelope (must match server).
|
|
type Message struct {
|
|
Type string `json:"type,omitempty"`
|
|
Text string `json:"text,omitempty"`
|
|
Signal json.RawMessage `json:"signal,omitempty"`
|
|
From string `json:"from,omitempty"`
|
|
To string `json:"to,omitempty"`
|
|
Role string `json:"role,omitempty"`
|
|
Source string `json:"source,omitempty"`
|
|
TS int64 `json:"ts"`
|
|
}
|
|
|
|
// Signal payload carried in Message.Signal.
|
|
type SignalPayload struct {
|
|
Kind string `json:"kind"` // "offer" | "answer" | "ice"
|
|
SDP *webrtc.SessionDescription `json:"sdp,omitempty"`
|
|
Candidate *webrtc.ICECandidateInit `json:"candidate,omitempty"`
|
|
}
|
|
|
|
var (
|
|
noClipboard bool
|
|
myLabel string
|
|
useRTC bool
|
|
rtcServer string
|
|
)
|
|
|
|
func main() {
|
|
server := flag.String("server", "https://tether.pecord.io", "tether-server base URL")
|
|
label := flag.String("label", "", "X-Tether-Client label (default: <os>-sse-<role>)")
|
|
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.BoolVar(&useRTC, "rtc", false, "act as a WebRTC mesh peer")
|
|
flag.Parse()
|
|
|
|
if *label == "" {
|
|
if *sendText != "" {
|
|
*label = defaultLabel("sender")
|
|
} else if useRTC {
|
|
*label = fmt.Sprintf("%s-rtc-listener", runtime.GOOS)
|
|
} else {
|
|
*label = defaultLabel("listener")
|
|
}
|
|
}
|
|
myLabel = *label
|
|
rtcServer = *server
|
|
|
|
if *sendText != "" {
|
|
send(*server, *label, *sendText, "clipboard", nil)
|
|
return
|
|
}
|
|
|
|
if !noClipboard && clipboard.Unsupported {
|
|
fmt.Fprintln(os.Stderr, "tether-client: OS clipboard unsupported on this platform; falling back to stdout-only")
|
|
noClipboard = true
|
|
}
|
|
|
|
if useRTC {
|
|
go presenceChirpLoop(*server)
|
|
}
|
|
|
|
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, msgType string, signal json.RawMessage) {
|
|
m := Message{Type: msgType, Text: text, Source: label, From: peerID, Signal: signal}
|
|
postMessage(server, label, m)
|
|
}
|
|
|
|
func sendMessage(server, label string, m Message) {
|
|
if m.From == "" {
|
|
m.From = peerID
|
|
}
|
|
if m.Source == "" {
|
|
m.Source = label
|
|
}
|
|
postMessage(server, label, m)
|
|
}
|
|
|
|
func postMessage(server, label string, m Message) {
|
|
body, _ := json.Marshal(m)
|
|
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.Printf("send: %v", err)
|
|
return
|
|
}
|
|
defer r.Body.Close()
|
|
if r.StatusCode >= 300 {
|
|
log.Printf("send: HTTP %d", r.StatusCode)
|
|
return
|
|
}
|
|
if m.Type == "clipboard" && m.Text != "" {
|
|
fmt.Println("sent.")
|
|
}
|
|
}
|
|
|
|
// listen subscribes to the SSE stream and dispatches messages to handlers.
|
|
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 (rtc=%v, peerID=%s)\n",
|
|
server, label, useRTC, peerID)
|
|
sc := bufio.NewScanner(r.Body)
|
|
sc.Buffer(make([]byte, 1<<20), 1<<20)
|
|
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 data != "" {
|
|
var m Message
|
|
if err := json.Unmarshal([]byte(data), &m); err == nil {
|
|
handleMessage(ev, m)
|
|
}
|
|
}
|
|
ev, data = "", ""
|
|
}
|
|
}
|
|
if err := sc.Err(); err != nil && err != io.EOF {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func handleMessage(ev string, m Message) {
|
|
// Ignore our own messages (echo).
|
|
if m.From == peerID {
|
|
return
|
|
}
|
|
switch m.Type {
|
|
case "", "clipboard":
|
|
ts := time.UnixMilli(m.TS).Format("15:04:05")
|
|
fmt.Printf("\n────── %s from %s ──────\n%s\n", ts, m.Source, m.Text)
|
|
if !noClipboard {
|
|
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")
|
|
}
|
|
}
|
|
case "presence":
|
|
if useRTC {
|
|
onPresence(m)
|
|
}
|
|
case "signal":
|
|
if useRTC {
|
|
onSignal(m)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── WebRTC mesh ─────────────────────────────────────────────────────────────
|
|
|
|
type remotePeer struct {
|
|
pc *webrtc.PeerConnection
|
|
dc *webrtc.DataChannel
|
|
lastSeen time.Time
|
|
state string // last connection state
|
|
}
|
|
|
|
var (
|
|
peers = make(map[string]*remotePeer) // keyed by remote peerID
|
|
peersMu sync.Mutex
|
|
)
|
|
|
|
func getPeer(id string) (*remotePeer, bool) {
|
|
peersMu.Lock()
|
|
defer peersMu.Unlock()
|
|
p, ok := peers[id]
|
|
return p, ok
|
|
}
|
|
|
|
func setPeer(id string, p *remotePeer) {
|
|
peersMu.Lock()
|
|
defer peersMu.Unlock()
|
|
peers[id] = p
|
|
}
|
|
|
|
func removePeer(id string) {
|
|
peersMu.Lock()
|
|
defer peersMu.Unlock()
|
|
if p, ok := peers[id]; ok {
|
|
if p.pc != nil {
|
|
_ = p.pc.Close()
|
|
}
|
|
delete(peers, id)
|
|
}
|
|
}
|
|
|
|
// presenceChirpLoop broadcasts our own presence every 10s and sweeps stale peers.
|
|
func presenceChirpLoop(server string) {
|
|
announce := func() {
|
|
sendMessage(server, myLabel, Message{
|
|
Type: "presence",
|
|
Role: "peer",
|
|
From: peerID,
|
|
})
|
|
}
|
|
announce()
|
|
t := time.NewTicker(10 * time.Second)
|
|
sweep := time.NewTicker(15 * time.Second)
|
|
defer t.Stop()
|
|
defer sweep.Stop()
|
|
for {
|
|
select {
|
|
case <-t.C:
|
|
announce()
|
|
case <-sweep.C:
|
|
peersMu.Lock()
|
|
for id, p := range peers {
|
|
if time.Since(p.lastSeen) > 45*time.Second {
|
|
fmt.Fprintf(os.Stderr, "rtc: peer %s timed out — tearing down\n", id[:8])
|
|
if p.pc != nil {
|
|
_ = p.pc.Close()
|
|
}
|
|
delete(peers, id)
|
|
}
|
|
}
|
|
peersMu.Unlock()
|
|
}
|
|
}
|
|
}
|
|
|
|
// onPresence: a remote participant announced themselves. If we don't yet
|
|
// have a peer connection to them, create one + send a targeted offer.
|
|
func onPresence(m Message) {
|
|
if m.From == "" || m.From == peerID {
|
|
return
|
|
}
|
|
p, exists := getPeer(m.From)
|
|
if exists {
|
|
// just refresh lastSeen
|
|
peersMu.Lock()
|
|
p.lastSeen = time.Now()
|
|
peersMu.Unlock()
|
|
return
|
|
}
|
|
// New peer — initiate
|
|
fmt.Fprintf(os.Stderr, "rtc: discovered new peer %s (role=%s) — sending offer\n",
|
|
m.From[:min(len(m.From), 8)], m.Role)
|
|
initiateOffer(m.From)
|
|
}
|
|
|
|
func initiateOffer(remoteID string) {
|
|
api := webrtc.NewAPI()
|
|
pc, err := api.NewPeerConnection(webrtc.Configuration{
|
|
ICEServers: []webrtc.ICEServer{{URLs: []string{"stun:stun.l.google.com:19302"}}},
|
|
})
|
|
if err != nil {
|
|
log.Printf("rtc: new pc for %s: %v", remoteID[:8], err)
|
|
return
|
|
}
|
|
dc, err := pc.CreateDataChannel("tether", nil)
|
|
if err != nil {
|
|
log.Printf("rtc: dc for %s: %v", remoteID[:8], err)
|
|
_ = pc.Close()
|
|
return
|
|
}
|
|
|
|
rp := &remotePeer{pc: pc, dc: dc, lastSeen: time.Now()}
|
|
setPeer(remoteID, rp)
|
|
|
|
dc.OnOpen(func() {
|
|
fmt.Fprintf(os.Stderr, "rtc: DataChannel OPEN with %s — P2P live\n", remoteID[:8])
|
|
})
|
|
dc.OnMessage(func(msg webrtc.DataChannelMessage) {
|
|
text := string(msg.Data)
|
|
fmt.Printf("\n[rtc:%s] %s\n", remoteID[:8], text)
|
|
if !noClipboard {
|
|
if err := clipboard.WriteAll(text); err == nil {
|
|
fmt.Fprintln(os.Stderr, " → clipboard updated (via rtc)")
|
|
}
|
|
}
|
|
})
|
|
pc.OnICECandidate(func(c *webrtc.ICECandidate) {
|
|
if c == nil {
|
|
return
|
|
}
|
|
init := c.ToJSON()
|
|
payload, _ := json.Marshal(SignalPayload{Kind: "ice", Candidate: &init})
|
|
sendMessage(rtcServer, myLabel, Message{
|
|
Type: "signal",
|
|
From: peerID,
|
|
To: remoteID,
|
|
Signal: payload,
|
|
})
|
|
})
|
|
pc.OnConnectionStateChange(func(s webrtc.PeerConnectionState) {
|
|
peersMu.Lock()
|
|
if p, ok := peers[remoteID]; ok {
|
|
p.state = s.String()
|
|
}
|
|
peersMu.Unlock()
|
|
fmt.Fprintf(os.Stderr, "rtc: %s state=%s\n", remoteID[:8], s)
|
|
if s == webrtc.PeerConnectionStateFailed || s == webrtc.PeerConnectionStateClosed {
|
|
removePeer(remoteID)
|
|
}
|
|
})
|
|
|
|
offer, err := pc.CreateOffer(nil)
|
|
if err != nil {
|
|
log.Printf("rtc: create offer for %s: %v", remoteID[:8], err)
|
|
return
|
|
}
|
|
if err := pc.SetLocalDescription(offer); err != nil {
|
|
log.Printf("rtc: set local desc for %s: %v", remoteID[:8], err)
|
|
return
|
|
}
|
|
payload, _ := json.Marshal(SignalPayload{Kind: "offer", SDP: &offer})
|
|
sendMessage(rtcServer, myLabel, Message{
|
|
Type: "signal",
|
|
From: peerID,
|
|
To: remoteID,
|
|
Signal: payload,
|
|
})
|
|
}
|
|
|
|
// onSignal: routed to the relevant peer connection by `To` field.
|
|
func onSignal(m Message) {
|
|
// Only handle signals targeted at us (or untargeted = legacy).
|
|
if m.To != "" && m.To != peerID {
|
|
return
|
|
}
|
|
var sp SignalPayload
|
|
if err := json.Unmarshal(m.Signal, &sp); err != nil {
|
|
return
|
|
}
|
|
p, ok := getPeer(m.From)
|
|
if !ok {
|
|
// Could be an answer/ice from someone we don't know about yet —
|
|
// shouldn't happen in normal flow because they wouldn't answer
|
|
// without first seeing our offer. Drop.
|
|
return
|
|
}
|
|
switch sp.Kind {
|
|
case "answer":
|
|
if sp.SDP != nil {
|
|
if err := p.pc.SetRemoteDescription(*sp.SDP); err != nil {
|
|
if !strings.Contains(err.Error(), "stable->SetRemote(answer)->stable") {
|
|
log.Printf("rtc: set remote desc for %s: %v", m.From[:8], err)
|
|
}
|
|
} else {
|
|
fmt.Fprintf(os.Stderr, "rtc: answer applied for %s\n", m.From[:8])
|
|
}
|
|
}
|
|
case "ice":
|
|
if sp.Candidate != nil {
|
|
if err := p.pc.AddICECandidate(*sp.Candidate); err != nil {
|
|
if !strings.Contains(err.Error(), "remote description is not set") {
|
|
log.Printf("rtc: add ice for %s: %v", m.From[:8], err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func min(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|