ua-labels: derive source from User-Agent, persist peerID in localStorage

Server now parses the User-Agent header on /api/send when no explicit
source is provided, producing labels like 'iphone-safari',
'macos-chrome', 'windows-firefox' so the feed shows where messages
came from at a glance.

Browser-side peerID is now stored in localStorage instead of being
freshly random on each page load — refreshing or reopening the tab
keeps the same identity for the same browser profile.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Claude Opus 4.7
2026-05-21 01:19:14 -05:00
parent 618d330682
commit 625143f87a
4 changed files with 46 additions and 4 deletions

1
go.mod
View File

@@ -12,6 +12,7 @@ require (
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/mileusna/useragent v1.3.5 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pion/datachannel v1.6.0 // indirect github.com/pion/datachannel v1.6.0 // indirect
github.com/pion/dtls/v3 v3.1.2 // indirect github.com/pion/dtls/v3 v3.1.2 // indirect

2
go.sum
View File

@@ -18,6 +18,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0= github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0=

View File

@@ -31,11 +31,45 @@ import (
"sync" "sync"
"time" "time"
"github.com/mileusna/useragent"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto" "github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
) )
// labelFromUA returns a short, human-friendly platform label like
// "iphone-safari", "macos-chrome", "windows-firefox". Falls back to "web".
func labelFromUA(uaStr string) string {
ua := useragent.Parse(uaStr)
platform := strings.ToLower(ua.OS)
switch {
case ua.IsIOS() && strings.Contains(strings.ToLower(ua.Device), "ipad"):
platform = "ipad"
case ua.IsIOS():
platform = "iphone"
case ua.IsAndroid():
platform = "android"
case ua.IsMacOS():
platform = "macos"
case ua.IsWindows():
platform = "windows"
case ua.IsLinux():
platform = "linux"
}
browser := strings.ToLower(ua.Name)
browser = strings.ReplaceAll(browser, " ", "")
if platform == "" && browser == "" {
return "web"
}
if browser == "" {
return platform
}
if platform == "" {
return browser
}
return platform + "-" + browser
}
//go:embed web //go:embed web
var webFS embed.FS var webFS embed.FS
@@ -251,7 +285,7 @@ func main() {
if m.Source == "" { if m.Source == "" {
m.Source = r.Header.Get("X-Tether-Source") m.Source = r.Header.Get("X-Tether-Source")
if m.Source == "" { if m.Source == "" {
m.Source = "web" m.Source = labelFromUA(r.Header.Get("User-Agent"))
} }
} }
m.TS = time.Now().UnixMilli() m.TS = time.Now().UnixMilli()

View File

@@ -151,7 +151,12 @@
if (!roomMatch) { window.location.replace("/"); } if (!roomMatch) { window.location.replace("/"); }
const room = roomMatch ? roomMatch[1] : ""; const room = roomMatch ? roomMatch[1] : "";
const fullUrl = window.location.origin + "/r/" + room; const fullUrl = window.location.origin + "/r/" + room;
const peerID = "browser-" + Array.from(crypto.getRandomValues(new Uint8Array(6))).map(b=>b.toString(16).padStart(2,"0")).join(""); // Stable per-device id — persists across reloads (unless cleared).
let peerID = localStorage.getItem("tether-peerID");
if (!peerID) {
peerID = "browser-" + Array.from(crypto.getRandomValues(new Uint8Array(6))).map(b=>b.toString(16).padStart(2,"0")).join("");
localStorage.setItem("tether-peerID", peerID);
}
$("roomTag").textContent = room; $("roomTag").textContent = room;
$("sessionUrl").textContent = fullUrl; $("sessionUrl").textContent = fullUrl;
@@ -218,16 +223,16 @@
const r = await fetch("/api/send?room=" + room, { const r = await fetch("/api/send?room=" + room, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "clipboard", text, source: "web", from: peerID, room }), body: JSON.stringify({ type: "clipboard", text, from: peerID, room }),
}); });
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"); }
}); });
// ── Signaling ───────────────────────────────────────────────────────── // ── Signaling ─────────────────────────────────────────────────────────
// Let the server fill in source from User-Agent unless caller specifies.
async function postMessage(m) { async function postMessage(m) {
m.from = m.from || peerID; m.from = m.from || peerID;
m.source = m.source || "web";
m.room = room; m.room = room;
await fetch("/api/send?room=" + room, { await fetch("/api/send?room=" + room, {
method: "POST", method: "POST",