v0.4: symmetric presence chirps + per-peer mesh
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>
This commit is contained in:
272
client/main.go
272
client/main.go
@@ -1,17 +1,20 @@
|
|||||||
// tether-client v0.3: SSE listener with optional WebRTC peer.
|
// tether-client v0.4: SSE listener with optional WebRTC mesh peer.
|
||||||
//
|
//
|
||||||
// Default flow (SSE only): subscribe to /api/stream, write incoming
|
// Default flow (SSE only): subscribe to /api/stream, write incoming
|
||||||
// clipboard messages to the OS clipboard. Works on Win/Linux/macOS.
|
// clipboard messages to the OS clipboard. Works on Win/Linux/macOS.
|
||||||
//
|
//
|
||||||
// With -rtc: also act as a WebRTC peer (Pion). Sends an SDP offer via
|
// With -rtc: become a WebRTC peer that maintains one RTCPeerConnection
|
||||||
// the signaling bus, accepts the browser's answer, then receives
|
// per active browser (mesh, not star). Symmetric "presence" chirps on
|
||||||
// clipboard payloads over a DataChannel — true P2P after ICE.
|
// 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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -28,7 +31,14 @@ import (
|
|||||||
"github.com/pion/webrtc/v4"
|
"github.com/pion/webrtc/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
const peerID = "tether-client"
|
// 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 {
|
func defaultLabel(role string) string {
|
||||||
return fmt.Sprintf("%s-%s-%s", runtime.GOOS, "sse", role)
|
return fmt.Sprintf("%s-%s-%s", runtime.GOOS, "sse", role)
|
||||||
@@ -40,6 +50,8 @@ type Message struct {
|
|||||||
Text string `json:"text,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
Signal json.RawMessage `json:"signal,omitempty"`
|
Signal json.RawMessage `json:"signal,omitempty"`
|
||||||
From string `json:"from,omitempty"`
|
From string `json:"from,omitempty"`
|
||||||
|
To string `json:"to,omitempty"`
|
||||||
|
Role string `json:"role,omitempty"`
|
||||||
Source string `json:"source,omitempty"`
|
Source string `json:"source,omitempty"`
|
||||||
TS int64 `json:"ts"`
|
TS int64 `json:"ts"`
|
||||||
}
|
}
|
||||||
@@ -55,6 +67,7 @@ var (
|
|||||||
noClipboard bool
|
noClipboard bool
|
||||||
myLabel string
|
myLabel string
|
||||||
useRTC bool
|
useRTC bool
|
||||||
|
rtcServer string
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -62,7 +75,7 @@ func main() {
|
|||||||
label := flag.String("label", "", "X-Tether-Client label (default: <os>-sse-<role>)")
|
label := flag.String("label", "", "X-Tether-Client label (default: <os>-sse-<role>)")
|
||||||
sendText := flag.String("send", "", "send this text and exit (otherwise listen)")
|
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(&noClipboard, "no-clipboard", false, "don't write incoming messages to the OS clipboard")
|
||||||
flag.BoolVar(&useRTC, "rtc", false, "enable WebRTC peer (uses signaling bus to negotiate)")
|
flag.BoolVar(&useRTC, "rtc", false, "act as a WebRTC mesh peer")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
if *label == "" {
|
if *label == "" {
|
||||||
@@ -75,6 +88,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
myLabel = *label
|
myLabel = *label
|
||||||
|
rtcServer = *server
|
||||||
|
|
||||||
if *sendText != "" {
|
if *sendText != "" {
|
||||||
send(*server, *label, *sendText, "clipboard", nil)
|
send(*server, *label, *sendText, "clipboard", nil)
|
||||||
@@ -87,7 +101,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if useRTC {
|
if useRTC {
|
||||||
go runRTCPeer(*server)
|
go presenceChirpLoop(*server)
|
||||||
}
|
}
|
||||||
|
|
||||||
for {
|
for {
|
||||||
@@ -100,6 +114,20 @@ func main() {
|
|||||||
|
|
||||||
func send(server, label, text, msgType string, signal json.RawMessage) {
|
func send(server, label, text, msgType string, signal json.RawMessage) {
|
||||||
m := Message{Type: msgType, Text: text, Source: label, From: peerID, Signal: signal}
|
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)
|
body, _ := json.Marshal(m)
|
||||||
req, _ := http.NewRequest("POST", server+"/api/send", bytes.NewReader(body))
|
req, _ := http.NewRequest("POST", server+"/api/send", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
@@ -114,13 +142,12 @@ func send(server, label, text, msgType string, signal json.RawMessage) {
|
|||||||
log.Printf("send: HTTP %d", r.StatusCode)
|
log.Printf("send: HTTP %d", r.StatusCode)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if msgType != "signal" {
|
if m.Type == "clipboard" && m.Text != "" {
|
||||||
fmt.Println("sent.")
|
fmt.Println("sent.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// listen subscribes to the SSE stream. Clipboard messages → OS clipboard.
|
// listen subscribes to the SSE stream and dispatches messages to handlers.
|
||||||
// Signal messages → forwarded to the WebRTC peer (if enabled).
|
|
||||||
func listen(server, label string) error {
|
func listen(server, label string) error {
|
||||||
req, _ := http.NewRequest("GET", server+"/api/stream", nil)
|
req, _ := http.NewRequest("GET", server+"/api/stream", nil)
|
||||||
req.Header.Set("X-Tether-Client", label)
|
req.Header.Set("X-Tether-Client", label)
|
||||||
@@ -132,7 +159,8 @@ func listen(server, label string) error {
|
|||||||
if r.StatusCode != 200 {
|
if r.StatusCode != 200 {
|
||||||
return fmt.Errorf("HTTP %d", r.StatusCode)
|
return fmt.Errorf("HTTP %d", r.StatusCode)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(os.Stderr, "tether-client: connected to %s as %q (rtc=%v)\n", server, label, useRTC)
|
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 := bufio.NewScanner(r.Body)
|
||||||
sc.Buffer(make([]byte, 1<<20), 1<<20)
|
sc.Buffer(make([]byte, 1<<20), 1<<20)
|
||||||
var ev, data string
|
var ev, data string
|
||||||
@@ -160,134 +188,240 @@ func listen(server, label string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleMessage(ev string, m Message) {
|
func handleMessage(ev string, m Message) {
|
||||||
|
// Ignore our own messages (echo).
|
||||||
|
if m.From == peerID {
|
||||||
|
return
|
||||||
|
}
|
||||||
switch m.Type {
|
switch m.Type {
|
||||||
case "", "clipboard":
|
case "", "clipboard":
|
||||||
ts := time.UnixMilli(m.TS).Format("15:04:05")
|
ts := time.UnixMilli(m.TS).Format("15:04:05")
|
||||||
fmt.Printf("\n────── %s from %s ──────\n%s\n", ts, m.Source, m.Text)
|
fmt.Printf("\n────── %s from %s ──────\n%s\n", ts, m.Source, m.Text)
|
||||||
if !noClipboard && m.Source != myLabel {
|
if !noClipboard {
|
||||||
if err := clipboard.WriteAll(m.Text); err != nil {
|
if err := clipboard.WriteAll(m.Text); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, " ! clipboard write error: %v\n", err)
|
fmt.Fprintf(os.Stderr, " ! clipboard write error: %v\n", err)
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintln(os.Stderr, " → clipboard updated")
|
fmt.Fprintln(os.Stderr, " → clipboard updated")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case "presence":
|
||||||
|
if useRTC {
|
||||||
|
onPresence(m)
|
||||||
|
}
|
||||||
case "signal":
|
case "signal":
|
||||||
if useRTC && m.From != peerID {
|
if useRTC {
|
||||||
incomingSignal <- m
|
onSignal(m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── WebRTC ────────────────────────────────────────────────────────────────
|
// ── WebRTC mesh ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type remotePeer struct {
|
||||||
|
pc *webrtc.PeerConnection
|
||||||
|
dc *webrtc.DataChannel
|
||||||
|
lastSeen time.Time
|
||||||
|
state string // last connection state
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
incomingSignal = make(chan Message, 16)
|
peers = make(map[string]*remotePeer) // keyed by remote peerID
|
||||||
rtcConnected = make(chan struct{})
|
peersMu sync.Mutex
|
||||||
)
|
)
|
||||||
|
|
||||||
func runRTCPeer(server string) {
|
func getPeer(id string) (*remotePeer, bool) {
|
||||||
api := webrtc.NewAPI()
|
peersMu.Lock()
|
||||||
config := webrtc.Configuration{
|
defer peersMu.Unlock()
|
||||||
ICEServers: []webrtc.ICEServer{
|
p, ok := peers[id]
|
||||||
{URLs: []string{"stun:stun.l.google.com:19302"}},
|
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()
|
||||||
}
|
}
|
||||||
pc, err := api.NewPeerConnection(config)
|
delete(peers, id)
|
||||||
if err != nil {
|
}
|
||||||
log.Printf("rtc: new peer connection: %v", err)
|
}
|
||||||
|
|
||||||
|
// 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
|
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)
|
dc, err := pc.CreateDataChannel("tether", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("rtc: create datachannel: %v", err)
|
log.Printf("rtc: dc for %s: %v", remoteID[:8], err)
|
||||||
|
_ = pc.Close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var once sync.Once
|
rp := &remotePeer{pc: pc, dc: dc, lastSeen: time.Now()}
|
||||||
|
setPeer(remoteID, rp)
|
||||||
|
|
||||||
dc.OnOpen(func() {
|
dc.OnOpen(func() {
|
||||||
fmt.Fprintln(os.Stderr, "rtc: DataChannel OPEN — P2P live")
|
fmt.Fprintf(os.Stderr, "rtc: DataChannel OPEN with %s — P2P live\n", remoteID[:8])
|
||||||
once.Do(func() { close(rtcConnected) })
|
|
||||||
})
|
})
|
||||||
dc.OnMessage(func(msg webrtc.DataChannelMessage) {
|
dc.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||||
text := string(msg.Data)
|
text := string(msg.Data)
|
||||||
fmt.Printf("\n[rtc] %s\n", text)
|
fmt.Printf("\n[rtc:%s] %s\n", remoteID[:8], text)
|
||||||
if !noClipboard {
|
if !noClipboard {
|
||||||
if err := clipboard.WriteAll(text); err == nil {
|
if err := clipboard.WriteAll(text); err == nil {
|
||||||
fmt.Fprintln(os.Stderr, " → clipboard updated (via rtc)")
|
fmt.Fprintln(os.Stderr, " → clipboard updated (via rtc)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
pc.OnICECandidate(func(c *webrtc.ICECandidate) {
|
pc.OnICECandidate(func(c *webrtc.ICECandidate) {
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
init := c.ToJSON()
|
init := c.ToJSON()
|
||||||
payload, _ := json.Marshal(SignalPayload{Kind: "ice", Candidate: &init})
|
payload, _ := json.Marshal(SignalPayload{Kind: "ice", Candidate: &init})
|
||||||
send(server, myLabel, "", "signal", payload)
|
sendMessage(rtcServer, myLabel, Message{
|
||||||
|
Type: "signal",
|
||||||
|
From: peerID,
|
||||||
|
To: remoteID,
|
||||||
|
Signal: payload,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
pc.OnConnectionStateChange(func(s webrtc.PeerConnectionState) {
|
pc.OnConnectionStateChange(func(s webrtc.PeerConnectionState) {
|
||||||
fmt.Fprintf(os.Stderr, "rtc: state=%s\n", s)
|
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)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create offer + post once immediately
|
|
||||||
offer, err := pc.CreateOffer(nil)
|
offer, err := pc.CreateOffer(nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("rtc: create offer: %v", err)
|
log.Printf("rtc: create offer for %s: %v", remoteID[:8], err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := pc.SetLocalDescription(offer); err != nil {
|
if err := pc.SetLocalDescription(offer); err != nil {
|
||||||
log.Printf("rtc: set local desc: %v", err)
|
log.Printf("rtc: set local desc for %s: %v", remoteID[:8], err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
payload, _ := json.Marshal(SignalPayload{Kind: "offer", SDP: &offer})
|
payload, _ := json.Marshal(SignalPayload{Kind: "offer", SDP: &offer})
|
||||||
send(server, myLabel, "", "signal", payload)
|
sendMessage(rtcServer, myLabel, Message{
|
||||||
fmt.Fprintln(os.Stderr, "rtc: offer posted, will chirp every 5s until paired...")
|
Type: "signal",
|
||||||
|
From: peerID,
|
||||||
|
To: remoteID,
|
||||||
|
Signal: payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// "Chirp": re-post the offer every 5s until DataChannel opens. Catches
|
// onSignal: routed to the relevant peer connection by `To` field.
|
||||||
// late-joining browsers without needing server-side history of signals.
|
func onSignal(m Message) {
|
||||||
go func() {
|
// Only handle signals targeted at us (or untargeted = legacy).
|
||||||
t := time.NewTicker(5 * time.Second)
|
if m.To != "" && m.To != peerID {
|
||||||
defer t.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-rtcConnected:
|
|
||||||
fmt.Fprintln(os.Stderr, "rtc: paired — chirping stopped")
|
|
||||||
return
|
return
|
||||||
case <-t.C:
|
|
||||||
p, _ := json.Marshal(SignalPayload{Kind: "offer", SDP: &offer})
|
|
||||||
send(server, myLabel, "", "signal", p)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Process incoming signals
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case msg := <-incomingSignal:
|
|
||||||
var sp SignalPayload
|
var sp SignalPayload
|
||||||
if err := json.Unmarshal(msg.Signal, &sp); err != nil {
|
if err := json.Unmarshal(m.Signal, &sp); err != nil {
|
||||||
continue
|
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 {
|
switch sp.Kind {
|
||||||
case "answer":
|
case "answer":
|
||||||
if sp.SDP != nil {
|
if sp.SDP != nil {
|
||||||
if err := pc.SetRemoteDescription(*sp.SDP); err != nil {
|
if err := p.pc.SetRemoteDescription(*sp.SDP); err != nil {
|
||||||
log.Printf("rtc: set remote desc: %v", err)
|
if !strings.Contains(err.Error(), "stable->SetRemote(answer)->stable") {
|
||||||
|
log.Printf("rtc: set remote desc for %s: %v", m.From[:8], err)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintln(os.Stderr, "rtc: answer applied")
|
fmt.Fprintf(os.Stderr, "rtc: answer applied for %s\n", m.From[:8])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "ice":
|
case "ice":
|
||||||
if sp.Candidate != nil {
|
if sp.Candidate != nil {
|
||||||
if err := pc.AddICECandidate(*sp.Candidate); err != nil {
|
if err := p.pc.AddICECandidate(*sp.Candidate); err != nil {
|
||||||
log.Printf("rtc: add ice: %v", err)
|
if !strings.Contains(err.Error(), "remote description is not set") {
|
||||||
|
log.Printf("rtc: add ice for %s: %v", m.From[:8], err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case <-context.Background().Done():
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|||||||
@@ -63,6 +63,9 @@
|
|||||||
}
|
}
|
||||||
.meta { font-size: 11px; color: #666; margin-top: 4px; }
|
.meta { font-size: 11px; color: #666; margin-top: 4px; }
|
||||||
.meta .rtc-badge { color: #4ade80; }
|
.meta .rtc-badge { color: #4ade80; }
|
||||||
|
.peers { font-size: 11px; color: #666; margin-top: 4px; display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.peers .peer { background: #131313; padding: 2px 8px; border-radius: 999px; border: 1px solid #1f1f1f; }
|
||||||
|
.peers .peer.rtc { color: #4ade80; border-color: #052e16; }
|
||||||
footer {
|
footer {
|
||||||
margin-top: auto; padding-top: 8px;
|
margin-top: auto; padding-top: 8px;
|
||||||
font-size: 11px; color: #555; text-align: center;
|
font-size: 11px; color: #555; text-align: center;
|
||||||
@@ -73,12 +76,14 @@
|
|||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<h1>tether</h1>
|
<h1>tether</h1>
|
||||||
<span class="tag">phone ↔ laptop</span>
|
<span class="tag">mesh clipboard</span>
|
||||||
<span class="pill" id="rtcPill">sse</span>
|
<span class="pill" id="rtcPill">sse</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div class="peers" id="peerList"></div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label for="out">send to laptop</label>
|
<label for="out">send</label>
|
||||||
<textarea id="out" placeholder="paste or type something…"></textarea>
|
<textarea id="out" placeholder="paste or type something…"></textarea>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button id="pasteBtn">paste clipboard</button>
|
<button id="pasteBtn">paste clipboard</button>
|
||||||
@@ -89,18 +94,18 @@
|
|||||||
<div class="status" id="status">idle</div>
|
<div class="status" id="status">idle</div>
|
||||||
|
|
||||||
<div class="feed">
|
<div class="feed">
|
||||||
<h2>received from laptop</h2>
|
<h2>received</h2>
|
||||||
<div id="incoming"></div>
|
<div id="incoming"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<a href="https://gitea.pecord.io/pecord/tether">tether on gitea</a> · v0.3
|
<a href="https://gitea.pecord.io/pecord/tether">tether on gitea</a> · v0.4
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
const peerID = "tether-browser-" + Math.random().toString(36).slice(2, 8);
|
const peerID = "browser-" + Array.from(crypto.getRandomValues(new Uint8Array(6))).map(b=>b.toString(16).padStart(2,"0")).join("");
|
||||||
let rtcChannel = null; // open RTCDataChannel, when we have one
|
const remotePeers = new Map(); // remoteID -> {pc, dc, lastSeen, status}
|
||||||
|
|
||||||
const status = (msg, cls) => {
|
const status = (msg, cls) => {
|
||||||
const s = $("status");
|
const s = $("status");
|
||||||
@@ -112,6 +117,25 @@
|
|||||||
p.textContent = mode;
|
p.textContent = mode;
|
||||||
p.className = "pill " + (mode === "rtc" ? "live" : mode === "negotiating" ? "connecting" : "");
|
p.className = "pill " + (mode === "rtc" ? "live" : mode === "negotiating" ? "connecting" : "");
|
||||||
};
|
};
|
||||||
|
function refreshPill() {
|
||||||
|
let anyRTC = false;
|
||||||
|
let anyConnecting = false;
|
||||||
|
for (const r of remotePeers.values()) {
|
||||||
|
if (r.dc && r.dc.readyState === "open") anyRTC = true;
|
||||||
|
else if (r.status === "connecting" || r.status === "new") anyConnecting = true;
|
||||||
|
}
|
||||||
|
setPill(anyRTC ? "rtc" : (anyConnecting ? "negotiating" : "sse"));
|
||||||
|
}
|
||||||
|
function renderPeers() {
|
||||||
|
const el = $("peerList");
|
||||||
|
el.innerHTML = "";
|
||||||
|
for (const [id, r] of remotePeers.entries()) {
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.className = "peer" + ((r.dc && r.dc.readyState === "open") ? " rtc" : "");
|
||||||
|
span.textContent = id.slice(0, 12) + (r.dc?.readyState === "open" ? " 🟢" : " ⋯");
|
||||||
|
el.appendChild(span);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function addIncoming(text, source, ts, viaRTC) {
|
function addIncoming(text, source, ts, viaRTC) {
|
||||||
const el = document.createElement("div");
|
const el = document.createElement("div");
|
||||||
@@ -138,10 +162,19 @@
|
|||||||
$("sendBtn").addEventListener("click", async () => {
|
$("sendBtn").addEventListener("click", async () => {
|
||||||
const text = $("out").value;
|
const text = $("out").value;
|
||||||
if (!text) { status("empty", "err"); return; }
|
if (!text) { status("empty", "err"); return; }
|
||||||
if (rtcChannel && rtcChannel.readyState === "open") {
|
|
||||||
rtcChannel.send(text);
|
// Fan out to all open DataChannels first
|
||||||
status("sent via rtc ✓", "ok");
|
let rtcSent = 0;
|
||||||
} else {
|
for (const r of remotePeers.values()) {
|
||||||
|
if (r.dc && r.dc.readyState === "open") {
|
||||||
|
try { r.dc.send(text); rtcSent++; } catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rtcSent > 0) {
|
||||||
|
status(`sent via rtc to ${rtcSent} peer(s) ✓`, "ok");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Fallback to HTTP for anyone not RTC-paired
|
||||||
status("sending via http…");
|
status("sending via http…");
|
||||||
try {
|
try {
|
||||||
const r = await fetch("/api/send", {
|
const r = await fetch("/api/send", {
|
||||||
@@ -151,80 +184,100 @@
|
|||||||
});
|
});
|
||||||
status(r.ok ? "delivered ✓" : "server returned " + r.status, r.ok ? "ok" : "err");
|
status(r.ok ? "delivered ✓" : "server returned " + r.status, r.ok ? "ok" : "err");
|
||||||
} catch (e) { status("network error", "err"); }
|
} catch (e) { status("network error", "err"); }
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── WebRTC peer ────────────────────────────────────────────────────────
|
// ── Signaling ─────────────────────────────────────────────────────────
|
||||||
// Browser acts as ANSWERER. Listens for offers via SSE, replies via /api/send.
|
async function postMessage(m) {
|
||||||
async function postSignal(payload) {
|
m.from = m.from || peerID;
|
||||||
|
m.source = m.source || "web";
|
||||||
await fetch("/api/send", {
|
await fetch("/api/send", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(m),
|
||||||
type: "signal",
|
|
||||||
from: peerID,
|
|
||||||
source: "web",
|
|
||||||
signal: payload,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
async function postSignal(toID, payload) {
|
||||||
|
await postMessage({ type: "signal", to: toID, signal: payload });
|
||||||
|
}
|
||||||
|
|
||||||
let pc = null;
|
// We accept incoming offers (we're the answerer). Create a fresh PC per
|
||||||
async function handleOffer(sdp) {
|
// remote peer.
|
||||||
if (pc) try { pc.close(); } catch (_) {}
|
async function handleOffer(fromID, sdp) {
|
||||||
pc = new RTCPeerConnection({
|
// If we already have a pc for this peer, close it (re-negotiate).
|
||||||
|
let r = remotePeers.get(fromID);
|
||||||
|
if (r && r.pc) { try { r.pc.close(); } catch (_) {} }
|
||||||
|
|
||||||
|
const pc = new RTCPeerConnection({
|
||||||
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
|
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
|
||||||
});
|
});
|
||||||
setPill("negotiating");
|
r = { pc, dc: null, lastSeen: Date.now(), status: "new" };
|
||||||
|
remotePeers.set(fromID, r);
|
||||||
|
refreshPill();
|
||||||
|
renderPeers();
|
||||||
|
|
||||||
pc.onicecandidate = (ev) => {
|
pc.onicecandidate = (ev) => {
|
||||||
if (ev.candidate) postSignal({ kind: "ice", candidate: ev.candidate.toJSON() });
|
if (ev.candidate) postSignal(fromID, { kind: "ice", candidate: ev.candidate.toJSON() });
|
||||||
};
|
};
|
||||||
pc.onconnectionstatechange = () => {
|
pc.onconnectionstatechange = () => {
|
||||||
if (pc.connectionState === "connected") setPill("rtc");
|
r.status = pc.connectionState;
|
||||||
else if (pc.connectionState === "failed" || pc.connectionState === "disconnected") setPill("sse");
|
refreshPill();
|
||||||
|
renderPeers();
|
||||||
|
if (pc.connectionState === "failed" || pc.connectionState === "closed") {
|
||||||
|
remotePeers.delete(fromID);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
pc.ondatachannel = (ev) => {
|
pc.ondatachannel = (ev) => {
|
||||||
const ch = ev.channel;
|
const ch = ev.channel;
|
||||||
ch.onopen = () => { rtcChannel = ch; setPill("rtc"); };
|
r.dc = ch;
|
||||||
ch.onclose = () => { rtcChannel = null; setPill("sse"); };
|
ch.onopen = () => { refreshPill(); renderPeers(); };
|
||||||
ch.onmessage = (m) => addIncoming(m.data, "laptop", Date.now(), true);
|
ch.onclose = () => { refreshPill(); renderPeers(); };
|
||||||
|
ch.onmessage = (m) => addIncoming(m.data, fromID.slice(0, 12), Date.now(), true);
|
||||||
};
|
};
|
||||||
|
|
||||||
await pc.setRemoteDescription({ type: "offer", sdp });
|
await pc.setRemoteDescription({ type: "offer", sdp });
|
||||||
const answer = await pc.createAnswer();
|
const answer = await pc.createAnswer();
|
||||||
await pc.setLocalDescription(answer);
|
await pc.setLocalDescription(answer);
|
||||||
await postSignal({ kind: "answer", sdp: answer });
|
await postSignal(fromID, { kind: "answer", sdp: answer });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleIceCandidate(candidate) {
|
async function handleIce(fromID, candidate) {
|
||||||
if (pc) {
|
const r = remotePeers.get(fromID);
|
||||||
try { await pc.addIceCandidate(candidate); } catch (e) {}
|
if (r && r.pc) {
|
||||||
|
try { await r.pc.addIceCandidate(candidate); } catch (e) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Presence chirp ────────────────────────────────────────────────────
|
||||||
|
setInterval(() => {
|
||||||
|
postMessage({ type: "presence", role: "browser" }).catch(() => {});
|
||||||
|
}, 10000);
|
||||||
|
// also do one immediately on load
|
||||||
|
postMessage({ type: "presence", role: "browser" }).catch(() => {});
|
||||||
|
|
||||||
// ── SSE main feed ──────────────────────────────────────────────────────
|
// ── SSE main feed ──────────────────────────────────────────────────────
|
||||||
function connectFeed() {
|
function connectFeed() {
|
||||||
const es = new EventSource("/api/stream");
|
const es = new EventSource("/api/stream");
|
||||||
es.addEventListener("clipboard", (ev) => {
|
es.addEventListener("clipboard", (ev) => {
|
||||||
try {
|
try {
|
||||||
const m = JSON.parse(ev.data);
|
const m = JSON.parse(ev.data);
|
||||||
if (m.from !== peerID) { // hide our own sends, show everyone else
|
if (m.from !== peerID) addIncoming(m.text, m.source, m.ts, false);
|
||||||
addIncoming(m.text, m.source, m.ts, false);
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
});
|
});
|
||||||
es.addEventListener("signal", (ev) => {
|
es.addEventListener("signal", (ev) => {
|
||||||
try {
|
try {
|
||||||
const m = JSON.parse(ev.data);
|
const m = JSON.parse(ev.data);
|
||||||
if (m.from === peerID) return; // ignore our own
|
if (m.from === peerID) return;
|
||||||
|
if (m.to && m.to !== peerID) return; // not for us
|
||||||
const payload = m.signal;
|
const payload = m.signal;
|
||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
if (payload.kind === "offer" && payload.sdp) {
|
if (payload.kind === "offer" && payload.sdp) handleOffer(m.from, payload.sdp.sdp);
|
||||||
handleOffer(payload.sdp.sdp);
|
else if (payload.kind === "ice" && payload.candidate) handleIce(m.from, payload.candidate);
|
||||||
} else if (payload.kind === "ice" && payload.candidate) {
|
|
||||||
handleIceCandidate(payload.candidate);
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
});
|
});
|
||||||
|
es.addEventListener("presence", (ev) => {
|
||||||
|
// Browsers just note presence; peers (Go side) act on it.
|
||||||
|
// We could render a "peer online" indicator here later.
|
||||||
|
});
|
||||||
es.onerror = () => setTimeout(connectFeed, 2000);
|
es.onerror = () => setTimeout(connectFeed, 2000);
|
||||||
}
|
}
|
||||||
connectFeed();
|
connectFeed();
|
||||||
|
|||||||
Reference in New Issue
Block a user