🎉 live server seems to be working now
This commit is contained in:
277
third_party/sendspin-go/internal/discovery/mdns.go
vendored
Normal file
277
third_party/sendspin-go/internal/discovery/mdns.go
vendored
Normal file
@@ -0,0 +1,277 @@
|
||||
// ABOUTME: mDNS service discovery for Sendspin Protocol
|
||||
// ABOUTME: Handles both advertisement (server-initiated) and browsing (client-initiated)
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/mdns"
|
||||
)
|
||||
|
||||
// silentLogger discards hashicorp/mdns internal logs (e.g. "[INFO] mdns: Closing client")
|
||||
var silentLogger = log.New(io.Discard, "", 0)
|
||||
|
||||
type Config struct {
|
||||
ServiceName string
|
||||
Port int
|
||||
ServerMode bool // If true, advertise as _sendspin-server._tcp, otherwise _sendspin._tcp
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
config Config
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
servers chan *ServerInfo
|
||||
clients chan *ClientInfo
|
||||
}
|
||||
|
||||
type ServerInfo struct {
|
||||
Name string
|
||||
Host string
|
||||
Port int
|
||||
}
|
||||
|
||||
// ClientInfo describes a discovered client (player) advertised via
|
||||
// _sendspin._tcp.local.
|
||||
type ClientInfo struct {
|
||||
Instance string // fully-qualified mDNS instance name (stable dedupe key)
|
||||
Name string // friendly name from TXT "name=" or falls back to Instance
|
||||
Host string // IPv4 address as a string
|
||||
Port int
|
||||
Path string // WebSocket path from TXT "path=" (default "/sendspin")
|
||||
}
|
||||
|
||||
// clientInfoFromEntry converts an mdns.ServiceEntry into a ClientInfo.
|
||||
// Returns nil when the entry lacks a usable IPv4 address or port, or when
|
||||
// the entry does not belong to the _sendspin._tcp service (hashicorp/mdns
|
||||
// is promiscuous and forwards any multicast response received during the
|
||||
// query window, not just matches to the queried service).
|
||||
func clientInfoFromEntry(entry *mdns.ServiceEntry) *ClientInfo {
|
||||
if entry == nil || entry.AddrV4 == nil || entry.Port == 0 {
|
||||
return nil
|
||||
}
|
||||
if !strings.Contains(entry.Name, "._sendspin._tcp.") {
|
||||
return nil
|
||||
}
|
||||
txt := parseTXT(entry.InfoFields)
|
||||
|
||||
path := txt["path"]
|
||||
if path == "" {
|
||||
path = "/sendspin"
|
||||
}
|
||||
name := txt["name"]
|
||||
if name == "" {
|
||||
name = entry.Name
|
||||
}
|
||||
|
||||
return &ClientInfo{
|
||||
Instance: entry.Name,
|
||||
Name: name,
|
||||
Host: entry.AddrV4.String(),
|
||||
Port: entry.Port,
|
||||
Path: path,
|
||||
}
|
||||
}
|
||||
|
||||
func NewManager(config Config) *Manager {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
return &Manager{
|
||||
config: config,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
servers: make(chan *ServerInfo, 10),
|
||||
clients: make(chan *ClientInfo, 10),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Advertise() error {
|
||||
ips, err := getLocalIPs()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get local IPs: %w", err)
|
||||
}
|
||||
|
||||
serviceType := "_sendspin._tcp"
|
||||
if m.config.ServerMode {
|
||||
serviceType = "_sendspin-server._tcp"
|
||||
}
|
||||
|
||||
service, err := mdns.NewMDNSService(
|
||||
m.config.ServiceName,
|
||||
serviceType,
|
||||
"",
|
||||
"",
|
||||
m.config.Port,
|
||||
ips,
|
||||
[]string{"path=/sendspin"},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create service: %w", err)
|
||||
}
|
||||
|
||||
server, err := mdns.NewServer(&mdns.Config{Zone: service, Logger: silentLogger})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create mdns server: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Advertising mDNS service: %s on port %d (type: %s)", m.config.ServiceName, m.config.Port, serviceType)
|
||||
|
||||
go func() {
|
||||
<-m.ctx.Done()
|
||||
server.Shutdown()
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Browse() error {
|
||||
go m.browseLoop()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) browseLoop() {
|
||||
for {
|
||||
select {
|
||||
case <-m.ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
entries := make(chan *mdns.ServiceEntry, 10)
|
||||
|
||||
go func() {
|
||||
for entry := range entries {
|
||||
// hashicorp/mdns is promiscuous: any multicast response arriving
|
||||
// on the socket during the query window is forwarded, regardless
|
||||
// of whether it matches Service. Filter by instance-name suffix
|
||||
// so we don't dial Google Cast, ADB, etc.
|
||||
if !strings.Contains(entry.Name, "._sendspin-server._tcp.") {
|
||||
continue
|
||||
}
|
||||
if entry.AddrV4 == nil || entry.Port == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
server := &ServerInfo{
|
||||
Name: entry.Name,
|
||||
Host: entry.AddrV4.String(),
|
||||
Port: entry.Port,
|
||||
}
|
||||
|
||||
log.Printf("Discovered server: %s at %s:%d", server.Name, server.Host, server.Port)
|
||||
|
||||
select {
|
||||
case m.servers <- server:
|
||||
case <-m.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
params := &mdns.QueryParam{
|
||||
Service: "_sendspin-server._tcp",
|
||||
Domain: "local",
|
||||
Timeout: 3 * time.Second,
|
||||
Entries: entries,
|
||||
Logger: silentLogger,
|
||||
}
|
||||
|
||||
mdns.Query(params)
|
||||
close(entries)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Servers() <-chan *ServerInfo {
|
||||
return m.servers
|
||||
}
|
||||
|
||||
func (m *Manager) Stop() {
|
||||
m.cancel()
|
||||
}
|
||||
|
||||
// BrowseClients searches for Sendspin clients advertising _sendspin._tcp.
|
||||
func (m *Manager) BrowseClients() error {
|
||||
go m.browseClientsLoop()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Clients() <-chan *ClientInfo {
|
||||
return m.clients
|
||||
}
|
||||
|
||||
func (m *Manager) browseClientsLoop() {
|
||||
for {
|
||||
select {
|
||||
case <-m.ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
entries := make(chan *mdns.ServiceEntry, 10)
|
||||
|
||||
go func() {
|
||||
for entry := range entries {
|
||||
info := clientInfoFromEntry(entry)
|
||||
if info == nil {
|
||||
continue
|
||||
}
|
||||
log.Printf("Discovered client: %s at %s:%d%s",
|
||||
info.Name, info.Host, info.Port, info.Path)
|
||||
select {
|
||||
case m.clients <- info:
|
||||
case <-m.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
params := &mdns.QueryParam{
|
||||
Service: "_sendspin._tcp",
|
||||
Domain: "local",
|
||||
Timeout: 3 * time.Second,
|
||||
Entries: entries,
|
||||
Logger: silentLogger,
|
||||
}
|
||||
|
||||
if err := mdns.Query(params); err != nil {
|
||||
log.Printf("mdns query error: %v", err)
|
||||
}
|
||||
close(entries)
|
||||
}
|
||||
}
|
||||
|
||||
func getLocalIPs() ([]net.IP, error) {
|
||||
var ips []net.IP
|
||||
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, iface := range ifaces {
|
||||
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
|
||||
if ipnet.IP.To4() != nil {
|
||||
ips = append(ips, ipnet.IP)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ips, nil
|
||||
}
|
||||
142
third_party/sendspin-go/internal/discovery/mdns_test.go
vendored
Normal file
142
third_party/sendspin-go/internal/discovery/mdns_test.go
vendored
Normal file
@@ -0,0 +1,142 @@
|
||||
// ABOUTME: Tests for mDNS discovery
|
||||
// ABOUTME: Tests service advertisement and discovery
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/mdns"
|
||||
)
|
||||
|
||||
func TestNewManager(t *testing.T) {
|
||||
config := Config{
|
||||
ServiceName: "Test Player",
|
||||
Port: 8927,
|
||||
}
|
||||
|
||||
mgr := NewManager(config)
|
||||
if mgr == nil {
|
||||
t.Fatal("expected manager to be created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientInfoFromEntry(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
entry *mdns.ServiceEntry
|
||||
want *ClientInfo
|
||||
}{
|
||||
{
|
||||
name: "full entry with path and name",
|
||||
entry: &mdns.ServiceEntry{
|
||||
Name: "living-room._sendspin._tcp.local.",
|
||||
Host: "living-room.local.",
|
||||
AddrV4: net.ParseIP("192.168.1.42"),
|
||||
Port: 8928,
|
||||
InfoFields: []string{"path=/sendspin", "name=Living Room"},
|
||||
},
|
||||
want: &ClientInfo{
|
||||
Instance: "living-room._sendspin._tcp.local.",
|
||||
Name: "Living Room",
|
||||
Host: "192.168.1.42",
|
||||
Port: 8928,
|
||||
Path: "/sendspin",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing path defaults to /sendspin",
|
||||
entry: &mdns.ServiceEntry{
|
||||
Name: "kitchen._sendspin._tcp.local.",
|
||||
AddrV4: net.ParseIP("192.168.1.43"),
|
||||
Port: 8928,
|
||||
InfoFields: []string{"name=Kitchen"},
|
||||
},
|
||||
want: &ClientInfo{
|
||||
Instance: "kitchen._sendspin._tcp.local.",
|
||||
Name: "Kitchen",
|
||||
Host: "192.168.1.43",
|
||||
Port: 8928,
|
||||
Path: "/sendspin",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing name falls back to entry.Name",
|
||||
entry: &mdns.ServiceEntry{
|
||||
Name: "bedroom._sendspin._tcp.local.",
|
||||
AddrV4: net.ParseIP("192.168.1.44"),
|
||||
Port: 8928,
|
||||
InfoFields: []string{"path=/sendspin"},
|
||||
},
|
||||
want: &ClientInfo{
|
||||
Instance: "bedroom._sendspin._tcp.local.",
|
||||
Name: "bedroom._sendspin._tcp.local.",
|
||||
Host: "192.168.1.44",
|
||||
Port: 8928,
|
||||
Path: "/sendspin",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no IPv4 address returns nil",
|
||||
entry: &mdns.ServiceEntry{
|
||||
Name: "ipv6-only._sendspin._tcp.local.",
|
||||
Port: 8928,
|
||||
InfoFields: []string{"path=/sendspin"},
|
||||
},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "zero port returns nil",
|
||||
entry: &mdns.ServiceEntry{
|
||||
Name: "bad._sendspin._tcp.local.",
|
||||
AddrV4: net.ParseIP("192.168.1.45"),
|
||||
Port: 0,
|
||||
InfoFields: []string{"path=/sendspin"},
|
||||
},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
// hashicorp/mdns is promiscuous and forwards any multicast
|
||||
// response, not just matches to the queried service. Entries
|
||||
// for other services must be filtered out or the server would
|
||||
// dial Google Cast, ADB, etc. as if they were sendspin clients.
|
||||
name: "non-sendspin service is filtered out",
|
||||
entry: &mdns.ServiceEntry{
|
||||
Name: "SHIELD._googlecast._tcp.local.",
|
||||
AddrV4: net.ParseIP("192.168.1.50"),
|
||||
Port: 8009,
|
||||
InfoFields: []string{},
|
||||
},
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := clientInfoFromEntry(tt.entry)
|
||||
if (got == nil) != (tt.want == nil) {
|
||||
t.Fatalf("clientInfoFromEntry nil-ness mismatch: got %+v, want %+v", got, tt.want)
|
||||
}
|
||||
if got == nil {
|
||||
return
|
||||
}
|
||||
if *got != *tt.want {
|
||||
t.Errorf("clientInfoFromEntry = %+v, want %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerExposesClientsChannel(t *testing.T) {
|
||||
mgr := NewManager(Config{ServiceName: "Test", Port: 8928})
|
||||
ch := mgr.Clients()
|
||||
if ch == nil {
|
||||
t.Fatal("Clients() returned nil channel")
|
||||
}
|
||||
// channel must be receive-only or bidirectional — just confirm we can select on it
|
||||
select {
|
||||
case <-ch:
|
||||
t.Fatal("unexpected value on empty clients channel")
|
||||
default:
|
||||
}
|
||||
}
|
||||
25
third_party/sendspin-go/internal/discovery/txt.go
vendored
Normal file
25
third_party/sendspin-go/internal/discovery/txt.go
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
// ABOUTME: Pure TXT record parsing for mDNS service entries
|
||||
// ABOUTME: Converts []string of "key=value" entries to map[string]string
|
||||
|
||||
package discovery
|
||||
|
||||
import "strings"
|
||||
|
||||
// parseTXT converts an mDNS TXT record slice (each element "key=value")
|
||||
// into a map. Empty strings are ignored. Keys without '=' are stored
|
||||
// with an empty value. When a key appears multiple times, the last
|
||||
// occurrence wins.
|
||||
func parseTXT(fields []string) map[string]string {
|
||||
out := make(map[string]string, len(fields))
|
||||
for _, f := range fields {
|
||||
if f == "" {
|
||||
continue
|
||||
}
|
||||
if idx := strings.Index(f, "="); idx >= 0 {
|
||||
out[f[:idx]] = f[idx+1:]
|
||||
} else {
|
||||
out[f] = ""
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
62
third_party/sendspin-go/internal/discovery/txt_test.go
vendored
Normal file
62
third_party/sendspin-go/internal/discovery/txt_test.go
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
// ABOUTME: Tests for TXT record parsing
|
||||
// ABOUTME: Pure parser, no mDNS dependency
|
||||
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseTXT(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fields []string
|
||||
want map[string]string
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
fields: nil,
|
||||
want: map[string]string{},
|
||||
},
|
||||
{
|
||||
name: "single key",
|
||||
fields: []string{"path=/sendspin"},
|
||||
want: map[string]string{"path": "/sendspin"},
|
||||
},
|
||||
{
|
||||
name: "multiple keys",
|
||||
fields: []string{"path=/sendspin", "name=Living Room"},
|
||||
want: map[string]string{"path": "/sendspin", "name": "Living Room"},
|
||||
},
|
||||
{
|
||||
name: "key without value",
|
||||
fields: []string{"flag"},
|
||||
want: map[string]string{"flag": ""},
|
||||
},
|
||||
{
|
||||
name: "value contains equals",
|
||||
fields: []string{"token=abc=def=ghi"},
|
||||
want: map[string]string{"token": "abc=def=ghi"},
|
||||
},
|
||||
{
|
||||
name: "empty string ignored",
|
||||
fields: []string{"", "path=/sendspin"},
|
||||
want: map[string]string{"path": "/sendspin"},
|
||||
},
|
||||
{
|
||||
name: "duplicate key: last wins",
|
||||
fields: []string{"path=/old", "path=/new"},
|
||||
want: map[string]string{"path": "/new"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := parseTXT(tt.fields)
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("parseTXT(%v) = %v, want %v", tt.fields, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user