netbrowse: show IP, product/version on cards; sun/moon theme icons

- IP address shown in soft parens on the card header line, e.g.
  "infix.local (192.168.1.10)"; IPv4 preferred over IPv6, link-local
  skipped
- Product name and OS version (from mDNS TXT records "product=" and
  "ov=") shown as a secondary line below the header when available
- Theme toggle icons changed from ◐/●/○ to ◐/☽/☀ (system/dark/light)
- browse.go: introduce Host struct (addr, product, version, other, svcs)
  replacing the flat []Service map; scan() now returns map[string]Host
- Search also matches against IP, product, and version fields

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
This commit is contained in:
Joachim Wiberg
2026-03-09 19:25:27 +01:00
parent 62559655dc
commit 585907daae
2 changed files with 104 additions and 44 deletions
+73 -36
View File
@@ -8,12 +8,20 @@ import (
"strings"
)
// Service represents an mDNS service discovered on the network.
// Service represents a single mDNS service advertised by a host.
type Service struct {
Type string `json:"type"`
Name string `json:"name"`
URL string `json:"url"`
Other bool `json:"other"`
Type string `json:"type"`
Name string `json:"name"`
URL string `json:"url"`
}
// Host groups all services for one mDNS host with its metadata.
type Host struct {
Addr string `json:"addr"`
Product string `json:"product,omitempty"`
Version string `json:"version,omitempty"`
Other bool `json:"other"`
Svcs []Service `json:"svcs"`
}
type serviceInfo struct {
@@ -44,8 +52,8 @@ func hasK() bool {
}
// scan runs avahi-browse, parses the output, and returns discovered
// services grouped by link (hostname), sorted per host.
func scan() map[string][]Service {
// hosts with their services and metadata.
func scan() map[string]Host {
args := "-tarp"
if hasK() {
args += "k"
@@ -57,10 +65,13 @@ func scan() map[string][]Service {
return nil
}
hosts := make(map[string][]Service)
vvHosts := make(map[string]bool) // has vv=1 TXT record
legHosts := make(map[string]bool) // has on=Infix TXT record (legacy)
mgmtHosts := make(map[string]bool) // has at least one management service type
svcsMap := make(map[string][]Service)
addrMap := make(map[string]string) // preferred IP per host (IPv4 wins)
productMap := make(map[string]string)
versionMap := make(map[string]string)
vvHosts := make(map[string]bool) // has vv=1 TXT record
legHosts := make(map[string]bool) // has on=Infix TXT record (legacy)
mgmtHosts := make(map[string]bool) // has at least one management service type
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line == "" {
@@ -84,7 +95,6 @@ func scan() map[string][]Service {
}
info, known := knownServices[serviceType]
other := !known
displayName := info.displayName
urlTemplate := info.urlTemplate
if !known {
@@ -100,21 +110,44 @@ func scan() map[string][]Service {
mgmtHosts[link] = true
}
// Parse TXT records
var path, adminurl string
for _, record := range strings.Split(txt, " ") {
// Prefer IPv4; accept non-link-local IPv6 as fallback.
if family == "IPv4" {
addrMap[link] = address
} else if addrMap[link] == "" && !strings.HasPrefix(address, "fe80:") {
addrMap[link] = address
}
// Parse TXT records.
// avahi-browse -p quotes records containing spaces, e.g.
// "ty=Brother DCP-L3550CDW series" "adminurl=http://..."
// Split on the between-record boundary `" "` (close-quote space
// open-quote) to keep each record intact, then trim outer quotes.
var path, adminurl, product, version string
for _, record := range strings.Split(txt, "\" \"") {
stripped := strings.Trim(record, "\"")
switch {
case stripped == "vv=1":
vvHosts[link] = true
case stripped == "on=Infix":
legHosts[link] = true
case path == "" && strings.Contains(stripped, "path="):
path = stripped[strings.LastIndex(stripped, "path=")+5:]
case adminurl == "" && strings.Contains(stripped, "adminurl="):
adminurl = stripped[strings.LastIndex(stripped, "adminurl=")+9:]
case path == "" && strings.HasPrefix(stripped, "path="):
path = stripped[5:]
case adminurl == "" && strings.HasPrefix(stripped, "adminurl="):
adminurl = stripped[9:]
case product == "" && strings.HasPrefix(stripped, "product="):
product = stripped[8:]
case version == "" && strings.HasPrefix(stripped, "ov="):
version = stripped[3:]
}
}
// IPP/Bonjour printers encode product as "(Name)" — strip the parens.
product = strings.TrimPrefix(strings.TrimSuffix(product, ")"), "(")
if product != "" && productMap[link] == "" {
productMap[link] = product
}
if version != "" && versionMap[link] == "" {
versionMap[link] = version
}
var url string
if adminurl != "" {
@@ -128,30 +161,29 @@ func scan() map[string][]Service {
}
svc := Service{
Type: displayName,
Name: decode(serviceName),
URL: url,
Other: other,
Type: displayName,
Name: decode(serviceName),
URL: url,
}
// Deduplicate
dup := false
for _, existing := range hosts[link] {
for _, existing := range svcsMap[link] {
if existing.Type == svc.Type && existing.Name == svc.Name && existing.URL == svc.URL {
dup = true
break
}
}
if !dup {
hosts[link] = append(hosts[link], svc)
svcsMap[link] = append(svcsMap[link], svc)
}
}
// Sort services per host
for link := range hosts {
sort.SliceStable(hosts[link], func(i, j int) bool {
oi := typeOrder[hosts[link][i].Type]
oj := typeOrder[hosts[link][j].Type]
for link := range svcsMap {
sort.SliceStable(svcsMap[link], func(i, j int) bool {
oi := typeOrder[svcsMap[link][i].Type]
oj := typeOrder[svcsMap[link][j].Type]
if oi == 0 {
oi = 999
}
@@ -162,13 +194,18 @@ func scan() map[string][]Service {
})
}
// Default view shows only Infix devices. A host qualifies if it has
// vv=1 on a management service (to exclude Apple AirPlay collisions),
// or on=Infix for older firmware that predates vv=1.
for link := range hosts {
if len(hosts[link]) > 0 {
isInfix := (vvHosts[link] && mgmtHosts[link]) || legHosts[link]
hosts[link][0].Other = !isInfix
// Build final host map. Default view shows only Infix devices: a host
// qualifies if it has vv=1 on a management service (to exclude Apple
// AirPlay collisions), or on=Infix for older firmware predating vv=1.
hosts := make(map[string]Host)
for link, svcs := range svcsMap {
isInfix := (vvHosts[link] && mgmtHosts[link]) || legHosts[link]
hosts[link] = Host{
Addr: addrMap[link],
Product: productMap[link],
Version: versionMap[link],
Other: !isInfix,
Svcs: svcs,
}
}
+31 -8
View File
@@ -211,6 +211,16 @@
border-bottom: 1px solid var(--border);
word-break: break-all;
}
.card-addr {
font-weight: 400;
color: var(--text-dim);
font-size: 11px;
}
.card-meta {
padding: 5px 14px 6px;
font-size: 11px; color: var(--text-dim);
border-bottom: 1px solid var(--border);
}
.card-svcs { padding: 6px 0; }
.svc {
@@ -297,7 +307,7 @@
<button class="btn icon" id="btn-refresh" title="Refresh now"></button>
<button class="btn" id="btn-all" aria-pressed="false" title="Show all mDNS devices, not just Infix">All</button>
<button class="btn" id="btn-auto" aria-pressed="false" title="Toggle auto-refresh every 30 s">Auto<span id="cdspan"></span></button>
<button class="btn icon" id="btn-theme" title="Cycle theme: system → dark → light"></button>
<button class="btn icon" id="btn-theme" title="Cycle theme: system → dark → light"></button>
</div>
</div>
</header>
@@ -331,7 +341,7 @@
var btnRefresh = document.getElementById('btn-refresh');
// ── Theme ──────────────────────────────────────────────────────────────────
var THEME_ICONS = { system: '◐', dark: '', light: '' };
var THEME_ICONS = { system: '◐', dark: '', light: '' };
var THEME_ORDER = ['system', 'dark', 'light'];
function applyTheme() {
@@ -380,14 +390,15 @@
// Devices to show based on the All toggle
var inScope = entries.filter(function (e) {
return showAll || !(e[1].length > 0 && e[1][0].other);
return showAll || !e[1].other;
});
// Further filtered by the search query
var visible = inScope.filter(function (e) {
if (!q) return true;
var text = e[0];
e[1].forEach(function (s) { text += ' ' + s.type + ' ' + s.name; });
var h = e[1];
var text = e[0] + ' ' + (h.addr || '') + ' ' + (h.product || '') + ' ' + (h.version || '');
h.svcs.forEach(function (s) { text += ' ' + s.type + ' ' + s.name; });
return text.toLowerCase().indexOf(q) !== -1;
});
@@ -409,8 +420,8 @@
setFooter(visible.length, inScope.length, entries.length);
}
function makeCard(host, svcs) {
var rows = svcs.map(function (s) {
function makeCard(host, h) {
var rows = h.svcs.map(function (s) {
var cls = BADGE_CLASS[s.type] || 'b-other';
var badge = '<span class="badge ' + cls + '">' + esc(s.type) + '</span>';
var name = '<span class="svc-name">' + esc(s.name) + '</span>';
@@ -421,7 +432,19 @@
return '<div class="svc">' + badge + name + '</div>';
}).join('');
return '<div class="card"><div class="card-host">' + esc(host) + '</div>'
var hdrText = esc(host);
if (h.addr) hdrText += ' <span class="card-addr">(' + esc(h.addr) + ')</span>';
var meta = '';
if (h.product || h.version) {
var parts = [];
if (h.product) parts.push(esc(h.product));
if (h.version) parts.push(esc(h.version));
meta = '<div class="card-meta">' + parts.join(' ') + '</div>';
}
return '<div class="card"><div class="card-host">' + hdrText + '</div>'
+ meta
+ '<div class="card-svcs">' + rows + '</div></div>';
}