The menu-bar app, not the root daemon, opens the AirDrop share sheet for each finished job when the setting is on. The unsigned arm64 package installs the daemon and LaunchAgent, and Darwin DNSServiceRegister advertises the _universal and _print subtypes. Co-authored-by: George Lambert <marchon@gmail.com>
361 lines
11 KiB
Go
361 lines
11 KiB
Go
package netprint
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/job"
|
|
)
|
|
|
|
func TestAirDropDefaultsOffAndMenu(t *testing.T) {
|
|
if Defaults().AirDropOn() {
|
|
t.Fatal("AirDrop is opt-in")
|
|
}
|
|
if !strings.Contains(string(ExampleJSON("/tmp")), `"airDropEnabled": false`) {
|
|
t.Fatal("sample config should show the switch off")
|
|
}
|
|
st := Status{Running: true, SupportsAirDrop: true, AirDropEnabled: true, Advertise: true}
|
|
var sawDrop, sawAdv bool
|
|
for _, e := range BuildMenu(st) {
|
|
if e.Kind == "airdrop" {
|
|
sawDrop = e.Checked && e.Enabled && e.Label == "AirDrop stamped PDF after each job"
|
|
}
|
|
if e.Kind == "advertise" {
|
|
sawAdv = e.Checked && e.Label == "Advertise on the network"
|
|
}
|
|
}
|
|
if !sawDrop || !sawAdv {
|
|
t.Fatalf("drop %v advertise %v", sawDrop, sawAdv)
|
|
}
|
|
plain := BuildMenu(Status{Running: true})
|
|
for _, e := range plain {
|
|
if e.Kind == "airdrop" || e.Kind == "advertise" {
|
|
t.Fatal("AirDrop controls are macOS only")
|
|
}
|
|
}
|
|
line := discoveryLine(Status{Running: true, MDNSAdvertising: true, MDNSMode: "dns-sd", MDNSDetail: "DNSServiceRegister Verae Timestamp._ipp._tcp.local regtype _ipp._tcp,_universal,_print port 631; _verae._tcp"})
|
|
if line != "Discovery — advertised (Bonjour _universal and _print)" {
|
|
t.Fatal(line)
|
|
}
|
|
}
|
|
|
|
func TestAirDropAgainUsesSavedFiles(t *testing.T) {
|
|
clearAirDrop()
|
|
t.Cleanup(clearAirDrop)
|
|
prev := shareAirDropOffer
|
|
var got AirDropOffer
|
|
shareAirDropOffer = func(o AirDropOffer) error {
|
|
got = o
|
|
return nil
|
|
}
|
|
t.Cleanup(func() { shareAirDropOffer = prev })
|
|
rememberAirDrop(AirDropOffer{ReceiptID: "QmReceipt", PDF: "certificate.pdf", JSON: "receipt.json"})
|
|
items := withAirDropAgain(BuildMenu(Status{Running: true}))
|
|
var item MenuEntry
|
|
for _, e := range items {
|
|
if e.Kind == "airdrop-again" {
|
|
item = e
|
|
}
|
|
}
|
|
if item.Label != airDropAgainTitle+" · QmReceipt" {
|
|
t.Fatal(item.Label)
|
|
}
|
|
if err := Dispatch(Status{Running: true}, item); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.PDF != "certificate.pdf" || got.JSON != "receipt.json" || got.ReceiptID != "QmReceipt" {
|
|
t.Fatalf("%+v", got)
|
|
}
|
|
}
|
|
|
|
func TestStampJobTellsMenuBarNotTheDaemon(t *testing.T) {
|
|
cfg, path := testConfig(t)
|
|
on := true
|
|
cfg.AirDropEnabled = &on
|
|
s, err := Start(cfg, path, func(pdf, title, user string) (job.Result, error) {
|
|
dir := filepath.Dir(pdf)
|
|
cert := filepath.Join(dir, "certificate.pdf")
|
|
js := filepath.Join(dir, "scan.verae.json")
|
|
if err := os.WriteFile(cert, []byte("%PDF-cert"), 0o600); err != nil {
|
|
return job.Result{}, err
|
|
}
|
|
if err := os.WriteFile(js, []byte("{\"receipt_id\":\"QmAir\"}\n"), 0o600); err != nil {
|
|
return job.Result{}, err
|
|
}
|
|
return job.Result{SHA256: strings.Repeat("ab", 32), ReceiptID: "QmAir", CertificatePDF: cert, ReceiptJSON: js, OriginalPDF: pdf}, nil
|
|
}, &fakeCtrl{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
_ = s.Shutdown(ctx)
|
|
})
|
|
|
|
prev := shareAirDropOffer
|
|
shareAirDropOffer = func(AirDropOffer) error {
|
|
t.Error("daemon called AirDrop")
|
|
return nil
|
|
}
|
|
defer func() { shareAirDropOffer = prev }()
|
|
|
|
var mu sync.Mutex
|
|
var got AirDropOffer
|
|
var notes int
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
dir := t.TempDir()
|
|
go watchAirDrop(ctx, s.ControlURL(), dir, func(o AirDropOffer) error {
|
|
mu.Lock()
|
|
got = o
|
|
mu.Unlock()
|
|
return nil
|
|
}, func(AirDropOffer) { mu.Lock(); notes++; mu.Unlock() })
|
|
|
|
id, err := s.store.AllocateID()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ProcessJob(s.store, s.snapshot(), nil, id, "application/pdf", "Note", "stamp", []byte("%PDF-1.4 note"), s.stamp, nil, s.afterJob)
|
|
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
mu.Lock()
|
|
pdf := got.PDF
|
|
mu.Unlock()
|
|
if pdf != "" {
|
|
break
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
mu.Lock()
|
|
offer := got
|
|
n := notes
|
|
mu.Unlock()
|
|
if offer.ReceiptID != "QmAir" || n != 1 {
|
|
t.Fatalf("offer %+v notes %d", offer, n)
|
|
}
|
|
b, err := os.ReadFile(offer.PDF)
|
|
if err != nil || string(b) != "%PDF-cert" {
|
|
t.Fatalf("pdf %q %v", b, err)
|
|
}
|
|
jb, err := os.ReadFile(offer.JSON)
|
|
if err != nil || !strings.Contains(string(jb), "QmAir") {
|
|
t.Fatalf("json %q %v", jb, err)
|
|
}
|
|
if err := Dispatch(Status{Running: true, AirDropEnabled: true, ControlURL: s.ControlURL()}, MenuEntry{Kind: "airdrop", ID: idAirDrop}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
st, err := NewClient(cfg.ControlPort).Status()
|
|
if err != nil || st.AirDropEnabled {
|
|
t.Fatalf("toggle %+v %v", st.AirDropEnabled, err)
|
|
}
|
|
|
|
acked := false
|
|
deadline = time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
body := readEvents(t, stringsTrimRight(s.ControlURL())+"/api/v1/events?after=0")
|
|
if strings.Contains(body, ": ok") && !strings.Contains(body, "QmAir") {
|
|
acked = true
|
|
break
|
|
}
|
|
time.Sleep(30 * time.Millisecond)
|
|
}
|
|
if !acked {
|
|
t.Fatal("acked job was still replayed")
|
|
}
|
|
}
|
|
|
|
func readEvents(t *testing.T, url string) string {
|
|
t.Helper()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond)
|
|
defer cancel()
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
defer res.Body.Close()
|
|
b, _ := io.ReadAll(res.Body)
|
|
return string(b)
|
|
}
|
|
|
|
func stringsTrimRight(s string) string {
|
|
return strings.TrimRight(s, "/")
|
|
}
|
|
|
|
func TestDarwinDNSRegistersAirPrintSubtypes(t *testing.T) {
|
|
if dnsPort(631) != 0x7702 {
|
|
t.Fatalf("port %x", dnsPort(631))
|
|
}
|
|
svc := sampleService()
|
|
svc.StampURL = "http://192.168.1.184:631/api/stamp"
|
|
insts := serviceToDarwinDNS(svc)
|
|
if len(insts) != 2 {
|
|
t.Fatal(len(insts))
|
|
}
|
|
ipp, verae := insts[0], insts[1]
|
|
if ipp.RegType != "_ipp._tcp,_universal,_print" || ipp.Name != "Verae Timestamp" || ipp.InstanceName != "Verae Timestamp._ipp._tcp.local" {
|
|
t.Fatalf("ipp %+v", ipp)
|
|
}
|
|
if strings.Contains(ipp.InstanceName, "_sub") || strings.Contains(ipp.RegType, "_sub") || ipp.Host != "" {
|
|
t.Fatalf("subtype target or host %+v", ipp)
|
|
}
|
|
if verae.RegType != "_verae._tcp" || verae.InstanceName != "Verae Timestamp._verae._tcp.local" {
|
|
t.Fatalf("verae %+v", verae)
|
|
}
|
|
if !strings.Contains(strings.Join(verae.TXT, "\n"), "path=/api/stamp") || !strings.Contains(strings.Join(verae.TXT, "\n"), "url=http://192.168.1.184:631/api/stamp") {
|
|
t.Fatal(verae.TXT)
|
|
}
|
|
|
|
pkt := &fakePkt{}
|
|
prev := listenMDNSFn
|
|
called := 0
|
|
listenMDNSFn = func() (mdnsConn, error) {
|
|
called++
|
|
return pkt, nil
|
|
}
|
|
t.Cleanup(func() { listenMDNSFn = prev })
|
|
api := &fakeDNS{}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan error, 1)
|
|
var mu sync.Mutex
|
|
var report mdnsReport
|
|
go func() {
|
|
done <- advertiseForOS("darwin", ctx, func() Service { return svc }, api, func(r mdnsReport) {
|
|
mu.Lock()
|
|
report = r
|
|
mu.Unlock()
|
|
})
|
|
}()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) && api.count() < 2 {
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
if api.count() != 2 || called != 0 {
|
|
t.Fatalf("registered %d udp calls %d names %v", api.count(), called, api.names())
|
|
}
|
|
cancel()
|
|
if err := <-done; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mu.Lock()
|
|
detail := report.Detail
|
|
mu.Unlock()
|
|
if report.Mode != "dns-sd" || !strings.Contains(detail, "_ipp._tcp,_universal,_print") || !strings.Contains(detail, "_verae._tcp") || strings.Contains(detail, "PTR _ipp._tcp") {
|
|
t.Fatalf("%+v", report)
|
|
}
|
|
if len(api.deregistered) != 2 {
|
|
t.Fatalf("deregistered %d", len(api.deregistered))
|
|
}
|
|
}
|
|
|
|
func TestDarwinDNSFailureUsesFullUDP(t *testing.T) {
|
|
pkt := &fakePkt{}
|
|
prev := listenMDNSFn
|
|
listenMDNSFn = func() (mdnsConn, error) { return pkt, nil }
|
|
t.Cleanup(func() { listenMDNSFn = prev })
|
|
api := &fakeDNS{err: errors.New("dns_sd unavailable")}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- advertiseForOS("darwin", ctx, func() Service { return sampleService() }, api, nil)
|
|
}()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) && pkt.count() < 1 {
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
if pkt.count() < 1 {
|
|
t.Fatal("no UDP announcement")
|
|
}
|
|
if err := assertPrimaryPTRs(pkt.packet(0), "Verae Timestamp"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cancel()
|
|
if err := <-done; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestMacAirDropAndPackageSources(t *testing.T) {
|
|
checks := []struct {
|
|
file string
|
|
need []string
|
|
}{
|
|
{"airdrop_darwin.go", []string{"NSSharingService", nsAirDropService, "performWithItems:", airDropAgainTitle, "UNUserNotificationCenter"}},
|
|
{"../../platform/macos/airdrop/main.swift", []string{"NSSharingService(named: .sendViaAirDrop)", "perform(withItems:"}},
|
|
{"service_darwin.go", []string{"/Library/LaunchAgents/com.verae.netprint.tray.plist", "LimitLoadToSessionType", "Aqua", "netprint"}},
|
|
{"../../platform/macos/pkg/build-netprint-pkg.sh", []string{"VeraeNetPrint-macos-arm64-", "LaunchAgents", "LaunchDaemons"}},
|
|
{"../../docs/network-printer.md", []string{"right-click", "sudo installer -pkg", "Local Network", "not signed"}},
|
|
{"dnsservice_darwin.go", []string{"DNSServiceRegister", "DNSServiceProcessResult", "dnsPort"}},
|
|
{"advertise_darwin.go", []string{`advertiseForOS("darwin"`}},
|
|
}
|
|
for _, c := range checks {
|
|
b, err := os.ReadFile(c.file)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
text := string(b)
|
|
for _, needle := range c.need {
|
|
if !strings.Contains(text, needle) {
|
|
t.Fatalf("%s missing %s", c.file, needle)
|
|
}
|
|
}
|
|
}
|
|
// The daemon publishes an event. It does not call the share sheet.
|
|
b, err := os.ReadFile("events.go")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(string(b), "NSSharingService") || strings.Contains(string(b), "shareAirDrop") {
|
|
t.Fatal("job events must stay in the daemon")
|
|
}
|
|
}
|
|
|
|
func TestAirDropSettingRoundTrip(t *testing.T) {
|
|
cfg, path := testConfig(t)
|
|
s, err := Start(cfg, path, stampMock(t), &fakeCtrl{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
_ = s.Shutdown(ctx)
|
|
})
|
|
c := NewClient(cfg.ControlPort)
|
|
if err := c.SetAirDropEnabled(true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
st, err := c.Status()
|
|
if err != nil || !st.AirDropEnabled || st.Name != "Verae Timestamp" {
|
|
t.Fatalf("%+v %v", st, err)
|
|
}
|
|
saved, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(saved), `"airDropEnabled": true`) {
|
|
t.Fatal(string(saved))
|
|
}
|
|
var parsed Config
|
|
if err := json.Unmarshal(saved, &parsed); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !parsed.AirDropOn() || parsed.Name != "Verae Timestamp" {
|
|
t.Fatalf("%+v", parsed)
|
|
}
|
|
}
|