PrintToVeraeTimeStamp/internal/netprint/dnsservice.go
Cursor Agent 1d8dbac81a
Advertise AirPrint subtypes and keep job folders, formats, and the service log consistent.
Windows DnsServiceRegister now registers the Bonjour service-type form and the RFC subtype name so a Mac browse of _ipp._tcp,_universal can see the printer. document-format-supported matches the TXT pdl list, including JPEG. Job ids persist in next-job and job-N directories. The service log is written and flushed even when stderr fails. The tray is started by Task Scheduler or Explorer so setup is not its parent.

Co-authored-by: George Lambert <marchon@gmail.com>
2026-10-04 22:16:54 +00:00

138 lines
4.5 KiB
Go

package netprint
import (
"context"
"fmt"
"log"
"net"
"strings"
"time"
)
// dnsSDInstance is one DNS-SD registration (primary service or a subtype).
type dnsSDInstance struct {
InstanceName string
Host string
Port int
IPv4 []net.IP
TXT []string
}
// dnsSDClient registers a service with the operating system's DNS-SD API.
// On Windows 10 and later this is DnsServiceRegister in dnsapi.dll.
type dnsSDClient interface {
Register(dnsSDInstance) error
Deregister(dnsSDInstance) error
}
func serviceToDNS(s Service) []dnsSDInstance {
host := sanitizeLabel(s.Host) + ".local"
base := sanitizeLabel(s.Instance)
// The first name is the primary service. A Mac `dns-sd -B _ipp._tcp` already
// resolves it. AirPrint and `dns-sd -B _ipp._tcp,_universal` query the PTR
// `_universal._sub._ipp._tcp.local`, which has to point at that same instance.
// DnsServiceRegister has no subtype field. Windows accepts the subtype on the
// service type, Bonjour-style ("<instance>._ipp._tcp,_universal.local"), and
// some builds only honor the RFC 6763 instance name
// ("<instance>._universal._sub._ipp._tcp.local"). Register both. A failure of
// either spelling is logged and does not remove the primary advertisement.
names := []string{
base + "._ipp._tcp.local",
base + "._ipp._tcp,_universal.local",
base + "._ipp._tcp,_print.local",
base + "._universal._sub._ipp._tcp.local",
base + "._print._sub._ipp._tcp.local",
}
out := make([]dnsSDInstance, 0, len(names))
for _, name := range names {
out = append(out, dnsSDInstance{
InstanceName: name,
Host: host,
Port: s.Port,
IPv4: append([]net.IP(nil), s.IPs...),
TXT: append([]string(nil), s.TXT...),
})
}
return out
}
// advertiseWithDNS publishes the printer through DnsServiceRegister (or a test double)
// and blocks until ctx is canceled. The primary _ipp._tcp name must succeed.
// Subtype failures are logged and do not hide the primary advertisement.
func advertiseWithDNS(ctx context.Context, current func() Service, api dnsSDClient, sink mdnsSink) error {
if api == nil {
return fmt.Errorf("DnsServiceRegister client is nil")
}
insts := serviceToDNS(current())
if len(insts) == 0 {
return fmt.Errorf("DnsServiceRegister: empty service")
}
var registered []dnsSDInstance
for i, inst := range insts {
if err := api.Register(inst); err != nil {
if i == 0 {
return fmt.Errorf("DnsServiceRegister %s: %w", inst.InstanceName, err)
}
log.Printf("netprint: mDNS subtype %s not registered: %v", inst.InstanceName, err)
continue
}
registered = append(registered, inst)
log.Printf("netprint: mDNS advertised via DnsServiceRegister %s port %d", inst.InstanceName, inst.Port)
}
if len(registered) == 0 {
return fmt.Errorf("DnsServiceRegister registered nothing")
}
if sink != nil {
var subtypes []string
for _, inst := range registered[1:] {
subtypes = append(subtypes, inst.InstanceName)
}
detail := fmt.Sprintf("DnsServiceRegister %s port %d", registered[0].InstanceName, registered[0].Port)
report := mdnsReport{
Advertising: true,
Mode: "dns-sd",
Detail: detail,
LastSent: time.Now(),
}
if len(subtypes) > 0 {
report.Detail = detail + "; subtypes " + strings.Join(subtypes, ", ")
}
if len(registered) < len(insts) {
report.Err = "AirPrint subtype registration incomplete"
}
sink(report)
}
<-ctx.Done()
for _, inst := range registered {
if err := api.Deregister(inst); err != nil {
log.Printf("netprint: mDNS DnsServiceDeRegister %s: %v", inst.InstanceName, err)
}
}
log.Printf("netprint: mDNS DnsServiceRegister removed")
return nil
}
// advertiseForOS is the discovery strategy for goos.
// Windows tries DnsServiceRegister first so the advertisement is sent by Dnscache,
// which already owns UDP 5353. A shared UDP socket is the fallback.
func advertiseForOS(goos string, ctx context.Context, current func() Service, api dnsSDClient, sink mdnsSink) error {
if goos == "windows" {
err := advertiseWithDNS(ctx, current, api, sink)
if err == nil || ctx.Err() != nil {
return nil
}
log.Printf("netprint: DnsServiceRegister failed: %v; trying a shared UDP 5353 socket", err)
if sink != nil {
sink(mdnsReport{
Mode: "dns-sd",
Err: err.Error(),
Detail: "DnsServiceRegister failed; trying shared UDP 5353",
})
}
if udpErr := advertiseUDP(ctx, current, sink); udpErr != nil {
return fmt.Errorf("DnsServiceRegister: %v; shared UDP 5353: %w", err, udpErr)
}
return nil
}
return advertiseUDP(ctx, current, sink)
}