diff --git a/.github/workflows/installers.yml b/.github/workflows/installers.yml index d6a67cf..178ed55 100644 --- a/.github/workflows/installers.yml +++ b/.github/workflows/installers.yml @@ -14,7 +14,7 @@ jobs: with: go-version: "1.26.1" - name: Toolchain - run: sudo apt-get update && sudo apt-get install -y nsis + run: sudo apt-get update && sudo apt-get install -y nsis cpio g++ git - name: Build driver and network-printer installers run: VERSION=0.3.4 ./scripts/build-netprint-installers.sh - uses: actions/upload-artifact@v4 diff --git a/docs/network-printer.md b/docs/network-printer.md index a7a836c..6fed60a 100644 --- a/docs/network-printer.md +++ b/docs/network-printer.md @@ -6,10 +6,10 @@ The printer is off until you install the standalone package or turn on the drive | | Windows | macOS | Debian / Ubuntu | |---|---|---|---| -| Standalone installer | `VeraeNetPrint-windows-*-setup.exe` | `VeraeNetPrint-macos.pkg` | `verae-netprint_*_amd64.deb` | +| Standalone installer | `VeraeNetPrint-windows-*-setup.exe` | `VeraeNetPrint-macos-arm64-0.3.4.pkg` | `verae-netprint_*_amd64.deb` | | Driver installer option | Unchecked checkbox on the options page | Customize → “Network timestamp printer” (unselected) | debconf “Enable the Verae network timestamp printer?” (default no), or `apt install verae-netprint` | | Service | `VeraeNetPrint` | LaunchDaemon `com.verae.netprint` | `verae-netprint.service` | -| Tray / menu bar | `verae-netprint-tray.exe` (notification area) | `verae-print tray` (menu bar) | `verae-print tray` (StatusNotifier / AppIndicator) | +| Tray / menu bar | `verae-netprint-tray.exe` (notification area) | LaunchAgent `com.verae.netprint.tray` (`verae-print tray`, Aqua session) | `verae-print tray` (StatusNotifier / AppIndicator) | | Standalone command | `%ProgramFiles%\Verae\NetPrint\bin\verae-print.exe` | `/Library/Application Support/Verae/NetPrint/bin/verae-print` | `/usr/lib/verae-netprint/verae-print` | | Driver command | `%ProgramFiles%\Verae\PrintStamp\bin\verae-print.exe` | `/Library/Application Support/Verae/PrintStamp/bin/verae-print` | `/usr/lib/verae-print/verae-print` | | CLI on `PATH` | the driver binary when the driver is installed | `/usr/local/bin/verae-print` points at the driver when it is installed, otherwise at NetPrint | `/usr/bin/verae-print` points at the driver when it is installed, otherwise at NetPrint | @@ -22,7 +22,7 @@ The desktop driver's loopback helper stays on `127.0.0.1:8631`. The network prin 1. Install using either path above. The Windows setup starts the tray for the logged-in user at the end of install (not elevated), and again at the next login. macOS and Linux start the menu bar at the next login. 2. Open **Settings** from the icon and paste the same `xVerae-` signing key the drivers use (or sign in first with `verae-print setup` on that machine). The service stores the key; the tray does not edit config files. -3. On another device, add the printer **Verae Timestamp**. It is advertised with DNS-SD (`_ipp._tcp`, plus the AirPrint `_universal` and IPP Everywhere `_print` subtypes) and AirPrint text records. On Windows 10 and later `DnsServiceRegister` publishes only `._ipp._tcp.local`, including its SRV, TXT, and address records. It cannot publish a subtype PTR that points at that name, so the service also opens a shared UDP 5353 socket (`SO_REUSEADDR`, joined to `224.0.0.251` and `ff02::fb`) that answers only `_universal._sub._ipp._tcp` and `_print._sub._ipp._tcp`. Those PTRs point at `._ipp._tcp.local`. The UDP socket does not answer the base `_ipp._tcp` PTR and does not send SRV, TXT, or address records, so the two stacks do not advertise two copies of the printer. If `DnsServiceRegister` fails, the UDP socket advertises the full set instead. `dns-sd -B _ipp._tcp` and `dns-sd -B _ipp._tcp,_universal` each list the printer once. The IPP port is 631, or 8632 when 631 is already taken by CUPS. mDNS and the tray show the port that was actually bound. The listener is IPv4 plus `[::]` with `IPV6_V6ONLY`, so a connect to the host's link-local IPv6 address completes; our own A/AAAA records omit fe80 and 169.254. Get-Printer-Attributes includes `media-col-default` for US Letter and A4 and `document-format-supported` lists the same formats as the TXT `pdl` record: PDF, Apple Raster, PWG Raster, and JPEG. +3. On another device, add the printer **Verae Timestamp**. It is advertised with DNS-SD (`_ipp._tcp`, plus the AirPrint `_universal` and IPP Everywhere `_print` subtypes) and AirPrint text records. On macOS, **Advertise on the network** (on by default) calls `DNSServiceRegister` with regtype `_ipp._tcp,_universal,_print`. mDNSResponder publishes the subtype PTRs aimed at `._ipp._tcp.local`, which is what `dns-sd -B _ipp._tcp,_universal` and `dns-sd -B _ipp._tcp,_print` accept. A second registration advertises `_verae._tcp`. The host argument is empty so mDNSResponder uses this Mac's hostname and addresses. The UDP 5353 socket stays closed while that registration is up, so the printer is not listed twice. If `DNSServiceRegister` fails, the socket advertises the full set instead. On Windows 10 and later `DnsServiceRegister` publishes only `._ipp._tcp.local`, including its SRV, TXT, and address records. It cannot publish a subtype PTR that points at that name, so the service also opens a shared UDP 5353 socket (`SO_REUSEADDR`, joined to `224.0.0.251` and `ff02::fb`) that answers only `_universal._sub._ipp._tcp` and `_print._sub._ipp._tcp`. Those PTRs point at `._ipp._tcp.local`. The UDP socket does not answer the base `_ipp._tcp` PTR and does not send SRV, TXT, or address records, so the two stacks do not advertise two copies of the printer. If `DnsServiceRegister` fails, the UDP socket advertises the full set instead. `dns-sd -B _ipp._tcp` and `dns-sd -B _ipp._tcp,_universal` each list the printer once. The IPP port is 631, or 8632 when 631 is already taken by CUPS. mDNS and the tray show the port that was actually bound. The listener is IPv4 plus `[::]` with `IPV6_V6ONLY`, so a connect to the host's link-local IPv6 address completes; our own A/AAAA records omit fe80 and 169.254. Get-Printer-Attributes includes `media-col-default` for US Letter and A4 and `document-format-supported` lists the same formats as the TXT `pdl` record: PDF, Apple Raster, PWG Raster, and JPEG. 4. Print a PDF. Open **Receipts** from the tray, or browse to `http://:/receipts` (the AirPrint admin URL). The page lists recent jobs, newest first, with the stamped PDF and the original. Each job also has a verify link on `https://explorer.verae.ai/time`. The icon shows running or stopped, the printer name, a LAN address and port, how many jobs arrived today, whether discovery is advertising, and the receipts URL. It can start, stop, and restart the service, turn launch-at-login on or off for the service and for the icon, turn **Email stamped PDFs** on or off, open recent receipts, and open settings (printer name, forward-to printer, signing key, mail, and the receipts PIN). On Windows it can also allow Public networks. When the active network is Public and that option is off, the menu says other devices cannot reach the printer. @@ -55,7 +55,29 @@ An optional checkbox (silent `/QUEUE=1`) adds a local Windows printer named **Ve On Debian and Ubuntu, `postinst` opens the same three ports with `ufw` when ufw is installed and already active. The rules are removed when the last package is removed. -On macOS, the Application Firewall is per application (System Settings → Network → Firewall). Install runs `/usr/libexec/ApplicationFirewall/socketfilterfw --add` and `--unblockapp` on the service binary when that tool is present. If other devices still cannot connect, allow `verae-print` in that pane. +On macOS, the Application Firewall is per application (System Settings → Network → Firewall). Install runs `/usr/libexec/ApplicationFirewall/socketfilterfw --add` and `--unblockapp` on the service binary when that tool is present. If other devices still cannot connect, allow `verae-print` in that pane. The first time the service advertises or accepts a LAN connection, macOS asks once for Local Network permission for `verae-print`. Allow it. The menu-bar app may ask as well; allow that too. + +## macOS package + +The Apple Silicon installer is `VeraeNetPrint-macos-arm64-0.3.4.pkg`. It installs: + +- `/Library/Application Support/Verae/NetPrint/bin/verae-print` (the arm64 binary) +- LaunchDaemon `com.verae.netprint` (root, the print server) +- LaunchAgent `com.verae.netprint.tray` in `/Library/LaunchAgents`, limited to the Aqua session, running `verae-print tray` as the logged-in user + +The package from CI is not signed and not notarized. macOS blocks a normal double-click until the package is signed with a Developer ID Installer certificate and notarized. To install this unsigned package, right-click it in Finder and choose Open, then Open again. Or, in Terminal: + +```bash +sudo installer -pkg VeraeNetPrint-macos-arm64-0.3.4.pkg -target / +``` + +`postinstall` runs `verae-print netprint install --owner standalone`, which bootstraps the daemon into `system` and the menu-bar agent into the console user's `gui` domain. The agent is not the root daemon. AirDrop runs there. + +## AirDrop on the Mac + +Settings and the menu bar have **AirDrop stamped PDF after each job**. It is off until you turn it on. When a job completes, the daemon records it and tells the menu-bar app on `GET http://127.0.0.1:8630/api/v1/events` (an event stream on the loopback control API). The agent downloads the certificate and the receipt JSON through that API, because the daemon's files are mode `0600`. It then opens the AirDrop share sheet with both files, using `NSSharingService(named: .sendViaAirDrop)`. A notification shows the receipt ID and an **AirDrop again** action. The same action is on the menu. The root daemon never opens the sheet. + +`platform/macos/airdrop/main.swift` is that share sheet as a small helper (`verae-airdrop`). A Mac build with `swiftc` installs it next to `verae-print` and the agent uses it. The Linux arm64 build has no Swift compiler, so the same call is made from the menu-bar process. macOS also asks for notification permission the first time the agent posts **AirDrop again**. The control API listens only on `127.0.0.1:8630`. The receipts page is on the IPP port, not on the control port, and it stays on Private and Domain networks with the rest of the printer. diff --git a/internal/netprint/advertise_darwin.go b/internal/netprint/advertise_darwin.go index 416926a..3654049 100644 --- a/internal/netprint/advertise_darwin.go +++ b/internal/netprint/advertise_darwin.go @@ -2,5 +2,11 @@ package netprint +import "context" + // SO_REUSEPORT lets this responder share UDP 5353 with mDNSResponder. const soReusePort = 0x200 + +func advertisePlatform(ctx context.Context, current func() Service, sink mdnsSink) error { + return advertiseForOS("darwin", ctx, current, newDarwinDNS(), sink) +} diff --git a/internal/netprint/advertise_other.go b/internal/netprint/advertise_other.go new file mode 100644 index 0000000..e3eee2d --- /dev/null +++ b/internal/netprint/advertise_other.go @@ -0,0 +1,9 @@ +//go:build !windows && !darwin + +package netprint + +import "context" + +func advertisePlatform(ctx context.Context, current func() Service, sink mdnsSink) error { + return advertiseUDP(ctx, current, sink) +} diff --git a/internal/netprint/advertise_unix.go b/internal/netprint/advertise_unix.go index 5257b8d..6ae8ab8 100644 --- a/internal/netprint/advertise_unix.go +++ b/internal/netprint/advertise_unix.go @@ -36,7 +36,3 @@ func listenMDNS() (mdnsConn, error) { _ = conn.SetReadBuffer(1 << 20) return conn, nil } - -func advertisePlatform(ctx context.Context, current func() Service, sink mdnsSink) error { - return advertiseUDP(ctx, current, sink) -} diff --git a/internal/netprint/airdrop.go b/internal/netprint/airdrop.go new file mode 100644 index 0000000..838ff3a --- /dev/null +++ b/internal/netprint/airdrop.go @@ -0,0 +1,262 @@ +package netprint + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" +) + +// airDropAgainTitle is the notification action and the menu action. +const airDropAgainTitle = "AirDrop again" + +// nsAirDropService is NSSharingService.Name.sendViaAirDrop. +const nsAirDropService = "com.apple.share.AirDrop.send" + +// AirDropOffer is a stamped certificate and its receipt JSON, saved where +// the logged-in user can read them. +type AirDropOffer struct { + ID int + Seq int64 + ReceiptID string + PDF string + JSON string +} + +var ( + airDropMu sync.Mutex + airDropLast AirDropOffer + airDropHas bool + shareAirDropOffer = func(AirDropOffer) error { + return fmt.Errorf("AirDrop runs in the macOS menu-bar app") + } +) + +func rememberAirDrop(o AirDropOffer) { + airDropMu.Lock() + airDropLast = o + airDropHas = o.PDF != "" + airDropMu.Unlock() +} + +func currentAirDrop() (AirDropOffer, bool) { + airDropMu.Lock() + defer airDropMu.Unlock() + if !airDropHas { + return AirDropOffer{}, false + } + return airDropLast, true +} + +func clearAirDrop() { + airDropMu.Lock() + airDropLast = AirDropOffer{} + airDropHas = false + airDropMu.Unlock() +} + +func airDropAgain() error { + o, ok := currentAirDrop() + if !ok { + return fmt.Errorf("no stamped PDF to AirDrop") + } + return shareAirDropOffer(o) +} + +// withAirDropAgain adds the menu action for the last certificate the agent saved. +func withAirDropAgain(items []MenuEntry) []MenuEntry { + o, ok := currentAirDrop() + if !ok || o.ReceiptID == "" { + return items + } + extra := MenuEntry{ + ID: idAirDropAgain, Label: airDropAgainTitle + " · " + o.ReceiptID, + Enabled: true, Kind: "airdrop-again", + } + out := make([]MenuEntry, 0, len(items)+1) + inserted := false + for _, e := range items { + if !inserted && e.Kind == "quit" { + out = append(out, extra) + inserted = true + } + out = append(out, e) + } + if !inserted { + out = append(out, extra) + } + return out +} + +func airDropDir() string { + d, err := os.UserCacheDir() + if err != nil || d == "" { + d = os.TempDir() + } + return filepath.Join(d, "Verae", "AirDrop") +} + +// materializeAirDrop copies the certificate and receipt JSON through the +// loopback control API into dir. The daemon's files are mode 0600 and owned +// by root; the menu-bar user reads these copies instead. +func materializeAirDrop(ev JobEvent, dir string) (AirDropOffer, error) { + if ev.CertificateURL == "" { + return AirDropOffer{}, fmt.Errorf("job %d has no certificate", ev.ID) + } + dest := filepath.Join(dir, strconv.Itoa(ev.ID)) + if err := os.MkdirAll(dest, 0o700); err != nil { + return AirDropOffer{}, err + } + pdf := filepath.Join(dest, "certificate.pdf") + if err := downloadTo(ev.CertificateURL, pdf); err != nil { + return AirDropOffer{}, err + } + js := "" + if ev.ReceiptJSONURL != "" { + js = filepath.Join(dest, "receipt.json") + if err := downloadTo(ev.ReceiptJSONURL, js); err != nil { + return AirDropOffer{}, err + } + } + return AirDropOffer{ID: ev.ID, Seq: ev.Seq, ReceiptID: ev.ReceiptID, PDF: pdf, JSON: js}, nil +} + +func downloadTo(url, dest string) error { + res, err := airDropClient.Get(url) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return fmt.Errorf("GET %s: HTTP %d", url, res.StatusCode) + } + f, err := os.OpenFile(dest, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) + if err != nil { + return err + } + _, copyErr := io.Copy(f, io.LimitReader(res.Body, maxDocument)) + closeErr := f.Close() + if copyErr != nil { + return copyErr + } + return closeErr +} + +// airDropClient has no timeout: the event stream stays open. +var airDropClient = &http.Client{} + +// presentAirDrop saves the files, asks the GUI session to share them, shows +// the notification, and acknowledges the event so a later launch does not +// open the sheet again. +func presentAirDrop(ev JobEvent, base, dir string, share func(AirDropOffer) error, notify func(AirDropOffer)) error { + if !ev.AirDrop { + return nil + } + offer, err := materializeAirDrop(ev, dir) + if err != nil { + return err + } + rememberAirDrop(offer) + if err := share(offer); err != nil { + return err + } + if notify != nil { + notify(offer) + } + if err := ackAirDrop(base, offer.ID); err != nil { + log.Printf("netprint: airdrop ack job %d: %v", offer.ID, err) + } + return nil +} + +func ackAirDrop(base string, id int) error { + body := fmt.Sprintf(`{"id":%d}`, id) + req, err := http.NewRequest(http.MethodPost, strings.TrimRight(base, "/")+"/api/v1/airdrop/ack", strings.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + res, err := airDropClient.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + io.Copy(io.Discard, res.Body) + if res.StatusCode >= 300 { + return fmt.Errorf("ack HTTP %d", res.StatusCode) + } + return nil +} + +// watchAirDrop reads the loopback event stream and presents each opted-in job. +func watchAirDrop(ctx context.Context, base, dir string, share func(AirDropOffer) error, notify func(AirDropOffer)) { + after := int64(0) + seen := map[int64]bool{} + for ctx.Err() == nil { + url := strings.TrimRight(base, "/") + "/api/v1/events?after=" + strconv.FormatInt(after, 10) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return + } + res, err := airDropClient.Do(req) + if err != nil { + if ctx.Err() != nil { + return + } + select { + case <-ctx.Done(): + return + case <-time.After(time.Second): + } + continue + } + sc := bufio.NewScanner(res.Body) + sc.Buffer(make([]byte, 64*1024), 1<<20) + var name string + for sc.Scan() { + line := sc.Text() + switch { + case strings.HasPrefix(line, "event:"): + name = strings.TrimSpace(strings.TrimPrefix(line, "event:")) + case strings.HasPrefix(line, "data:") && name == "job": + var ev JobEvent + if json.Unmarshal([]byte(strings.TrimSpace(strings.TrimPrefix(line, "data:"))), &ev) != nil { + continue + } + if ev.Seq == 0 || seen[ev.Seq] { + continue + } + if ev.AirDrop { + if err := presentAirDrop(ev, base, dir, share, notify); err != nil { + res.Body.Close() + break + } + } + seen[ev.Seq] = true + if ev.Seq > after { + after = ev.Seq + } + case line == "": + name = "" + } + } + res.Body.Close() + if ctx.Err() != nil { + return + } + select { + case <-ctx.Done(): + return + case <-time.After(time.Second): + } + } +} diff --git a/internal/netprint/airdrop_darwin.go b/internal/netprint/airdrop_darwin.go new file mode 100644 index 0000000..8473fb6 --- /dev/null +++ b/internal/netprint/airdrop_darwin.go @@ -0,0 +1,317 @@ +//go:build darwin + +package netprint + +import ( + "context" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "runtime" + "sync" + "unsafe" + + "github.com/ebitengine/purego" + "github.com/ebitengine/purego/objc" +) + +const ( + airDropActionID = "airdrop-again" + airDropCategoryID = "verae.airdrop" + airDropDefaultTap = "com.apple.UNNotificationDefaultActionIdentifier" + unAlert = 4 // UNAuthorizationOptionAlert + unActionForeground = 4 // UNNotificationActionOptionForeground +) + +func init() { + shareAirDropOffer = shareAirDropPlatform +} + +func startAirDropWatch(ctx context.Context) { + prepareAirDropNotifications() + go watchAirDrop(ctx, "http://127.0.0.1:8630", airDropDir(), shareAirDropPlatform, notifyAirDrop) +} + +func airDropMethods() []objc.MethodDef { + return []objc.MethodDef{{ + Cmd: objc.RegisterName("userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:"), + Fn: didReceiveAirDrop, + }} +} + +func shareAirDropPlatform(o AirDropOffer) error { + if helper := airDropHelperPath(); helper != "" { + args := []string{o.PDF} + if o.JSON != "" { + args = append(args, o.JSON) + } + cmd := exec.Command(helper, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + log.Printf("netprint: verae-airdrop: %v", err) + } else { + return nil + } + } + return shareAirDropObjC(o) +} + +func airDropHelperPath() string { + exe, err := os.Executable() + if err != nil { + return "" + } + p := filepath.Join(filepath.Dir(exe), "verae-airdrop") + st, err := os.Stat(p) + if err != nil || st.IsDir() { + return "" + } + return p +} + +func shareAirDropObjC(o AirDropOffer) error { + var err error + onMain(func() { + err = performAirDrop(o) + }) + return err +} + +func performAirDrop(o AirDropOffer) error { + paths := []string{o.PDF} + if o.JSON != "" { + paths = append(paths, o.JSON) + } + // com.apple.share.AirDrop.send is NSSharingService.Name.sendViaAirDrop. + svc := cls("NSSharingService").Send(objc.RegisterName("sharingServiceNamed:"), nsString(nsAirDropService)) + if svc == 0 { + return fmt.Errorf("AirDrop sharing service is unavailable") + } + items := cls("NSMutableArray").Send(objc.RegisterName("array")) + for _, p := range paths { + if p == "" { + continue + } + url := cls("NSURL").Send(objc.RegisterName("fileURLWithPath:"), nsString(p)) + items.Send(objc.RegisterName("addObject:"), url) + } + app := cls("NSApplication").Send(objc.RegisterName("sharedApplication")) + app.Send(objc.RegisterName("activateIgnoringOtherApps:"), byte(1)) + svc.Send(objc.RegisterName("performWithItems:"), items) + runtime.KeepAlive(items) + log.Printf("netprint: AirDrop sheet receipt=%s", o.ReceiptID) + return nil +} + +func notifyAirDrop(o AirDropOffer) { + onMain(func() { postAirDropNote(o) }) +} + +func postAirDropNote(o AirDropOffer) { + if !notesReady { + return + } + center := cls("UNUserNotificationCenter").Send(objc.RegisterName("currentNotificationCenter")) + if center == 0 { + return + } + content := cls("UNMutableNotificationContent").Send(objc.RegisterName("alloc")).Send(objc.RegisterName("init")) + title := "Verae Timestamp" + body := "Receipt " + o.ReceiptID + if o.ReceiptID == "" { + body = fmt.Sprintf("Receipt job %d", o.ID) + } + content.Send(objc.RegisterName("setTitle:"), nsString(title)) + content.Send(objc.RegisterName("setBody:"), nsString(body)) + content.Send(objc.RegisterName("setCategoryIdentifier:"), nsString(airDropCategoryID)) + ident := o.ReceiptID + if ident == "" { + ident = fmt.Sprintf("job-%d", o.ID) + } + req := cls("UNNotificationRequest").Send( + objc.RegisterName("requestWithIdentifier:content:trigger:"), + nsString(ident), content, objc.ID(0), + ) + center.Send(objc.RegisterName("addNotificationRequest:withCompletionHandler:"), req, airDropBlock) + runtime.KeepAlive(content) + runtime.KeepAlive(req) +} + +var ( + notesReady bool + airDropBlock unsafe.Pointer + blockDesc blockDescriptor + blockLit blockLiteral + mainMu sync.Mutex + mainFn func() +) + +type blockLiteral struct { + isa uintptr + flags int32 + reserved int32 + invoke uintptr + descriptor uintptr +} + +type blockDescriptor struct { + reserved uintptr + size uintptr +} + +func prepareAirDropNotifications() { + if _, err := purego.Dlopen("/System/Library/Frameworks/UserNotifications.framework/UserNotifications", 1); err != nil { + log.Printf("netprint: UserNotifications: %v", err) + return + } + lib, err := purego.Dlopen("/usr/lib/libSystem.B.dylib", 1) + if err != nil { + log.Printf("netprint: libSystem: %v", err) + return + } + isa, err := purego.Dlsym(lib, "_NSConcreteGlobalBlock") + if err != nil || isa == 0 { + log.Printf("netprint: AirDrop notification block: %v", err) + return + } + blockDesc = blockDescriptor{size: unsafe.Sizeof(blockLiteral{})} + blockLit = blockLiteral{ + isa: isa, + flags: 1 << 28, // BLOCK_IS_GLOBAL + invoke: purego.NewCallback(mainBlockInvoke), + descriptor: uintptr(unsafe.Pointer(&blockDesc)), + } + airDropBlock = unsafe.Pointer(&blockLit) + onMain(func() { registerAirDropCategory() }) + notesReady = true +} + +func registerAirDropCategory() { + center := cls("UNUserNotificationCenter").Send(objc.RegisterName("currentNotificationCenter")) + if center == 0 || trayTarget == 0 { + return + } + center.Send(objc.RegisterName("setDelegate:"), trayTarget) + center.Send(objc.RegisterName("requestAuthorizationWithOptions:completionHandler:"), uintptr(unAlert), airDropBlock) + action := cls("UNNotificationAction").Send( + objc.RegisterName("actionWithIdentifier:title:options:"), + nsString(airDropActionID), nsString(airDropAgainTitle), uintptr(unActionForeground), + ) + actions := cls("NSArray").Send(objc.RegisterName("arrayWithObject:"), action) + empty := cls("NSArray").Send(objc.RegisterName("array")) + cat := cls("UNNotificationCategory").Send( + objc.RegisterName("categoryWithIdentifier:actions:intentIdentifiers:options:"), + nsString(airDropCategoryID), actions, empty, uintptr(0), + ) + set := cls("NSSet").Send(objc.RegisterName("setWithObject:"), cat) + center.Send(objc.RegisterName("setNotificationCategories:"), set) + runtime.KeepAlive(action) + runtime.KeepAlive(cat) +} + +func didReceiveAirDrop(self, _cmd, center, response, completion objc.ID) { + action := "" + if response != 0 { + action = objcUTF8(objc.Send[objc.ID](response, objc.RegisterName("actionIdentifier"))) + } + if action == airDropActionID || action == airDropDefaultTap || action == "" { + if err := airDropAgain(); err != nil { + log.Printf("netprint: AirDrop again: %v", err) + } + } + callVoidBlock(completion) +} + +func callVoidBlock(b objc.ID) { + if b == 0 { + return + } + invoke := *(*uintptr)(unsafe.Pointer(uintptr(b) + 16)) + if invoke == 0 { + return + } + purego.SyscallN(invoke, uintptr(b)) +} + +var onMainGate sync.Mutex + +func onMain(fn func()) { + if fn == nil { + return + } + if objc.Send[byte](cls("NSThread"), objc.RegisterName("isMainThread")) != 0 { + fn() + return + } + onMainGate.Lock() + defer onMainGate.Unlock() + if mainBlockPtr() == nil { + fn() + return + } + done := make(chan struct{}) + mainMu.Lock() + mainFn = func() { + fn() + close(done) + } + mainMu.Unlock() + queue := cls("NSOperationQueue").Send(objc.RegisterName("mainQueue")) + queue.Send(objc.RegisterName("addOperationWithBlock:"), airDropBlock) + <-done +} + +func mainBlockPtr() unsafe.Pointer { + if airDropBlock != nil { + return airDropBlock + } + lib, err := purego.Dlopen("/usr/lib/libSystem.B.dylib", 1) + if err != nil { + return nil + } + isa, err := purego.Dlsym(lib, "_NSConcreteGlobalBlock") + if err != nil { + return nil + } + blockDesc = blockDescriptor{size: unsafe.Sizeof(blockLiteral{})} + blockLit = blockLiteral{ + isa: isa, + flags: 1 << 28, + invoke: purego.NewCallback(mainBlockInvoke), + descriptor: uintptr(unsafe.Pointer(&blockDesc)), + } + airDropBlock = unsafe.Pointer(&blockLit) + return airDropBlock +} + +func mainBlockInvoke(uintptr) { + mainMu.Lock() + fn := mainFn + mainFn = nil + mainMu.Unlock() + if fn != nil { + fn() + } +} + +func objcUTF8(id objc.ID) string { + if id == 0 { + return "" + } + p := objc.Send[unsafe.Pointer](id, objc.RegisterName("UTF8String")) + if p == nil { + return "" + } + var b []byte + for i := uintptr(0); ; i++ { + c := *(*byte)(unsafe.Add(p, i)) + if c == 0 || i > 1<<20 { + break + } + b = append(b, c) + } + return string(b) +} diff --git a/internal/netprint/airdrop_other.go b/internal/netprint/airdrop_other.go new file mode 100644 index 0000000..b0276c9 --- /dev/null +++ b/internal/netprint/airdrop_other.go @@ -0,0 +1,19 @@ +//go:build !darwin + +package netprint + +import "context" + +func startAirDropWatch(context.Context) {} + +func shareAirDropPlatform(AirDropOffer) error { + return errAirDropMac +} + +var errAirDropMac = errAirDropUnavailable() + +type airDropUnavailable struct{} + +func (airDropUnavailable) Error() string { return "AirDrop runs in the macOS menu-bar app" } + +func errAirDropUnavailable() error { return airDropUnavailable{} } diff --git a/internal/netprint/airdrop_test.go b/internal/netprint/airdrop_test.go new file mode 100644 index 0000000..feeb1de --- /dev/null +++ b/internal/netprint/airdrop_test.go @@ -0,0 +1,361 @@ +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) + } +} diff --git a/internal/netprint/config.go b/internal/netprint/config.go index b360824..2245ba2 100644 --- a/internal/netprint/config.go +++ b/internal/netprint/config.go @@ -62,6 +62,10 @@ type Config struct { SMTPPort int `json:"smtpPort,omitempty"` SMTPTLS string `json:"smtpTLS,omitempty"` SMTPUser string `json:"smtpUser,omitempty"` + // AirDropEnabled asks the macOS menu-bar app to open the AirDrop share + // sheet after each completed job. Nil and false do not. The root daemon + // never presents the sheet; it only tells the user's agent. + AirDropEnabled *bool `json:"airDropEnabled,omitempty"` } const ( @@ -83,6 +87,9 @@ func (c Config) MDNS() bool { return c.Advertise == nil || *c.Advertise } // EmailOn reports whether completed jobs should be mailed. func (c Config) EmailOn() bool { return c.EmailEnabled != nil && *c.EmailEnabled } +// AirDropOn reports whether the menu-bar app should AirDrop each completed job. +func (c Config) AirDropOn() bool { return c.AirDropEnabled != nil && *c.AirDropEnabled } + // MailRecipients splits EmailTo. An empty setting uses DefaultEmailTo. func (c Config) MailRecipients() []string { raw := strings.TrimSpace(c.EmailTo) @@ -113,20 +120,22 @@ func Defaults() Config { keep := true adv := true emailOff := false + airDropOff := false return Config{ - Name: "Verae Timestamp", - Port: 631, - FallbackPort: DefaultFallbackPort, - ControlPort: DefaultControlPort, - DataDir: defaultDataDir(), - KeepPDF: &keep, - Advertise: &adv, - EmailEnabled: &emailOff, - EmailTo: DefaultEmailTo, - SMTPHost: DefaultSMTPHost, - SMTPPort: 587, - SMTPTLS: "starttls", - SMTPUser: DefaultEmailTo, + Name: "Verae Timestamp", + Port: 631, + FallbackPort: DefaultFallbackPort, + ControlPort: DefaultControlPort, + DataDir: defaultDataDir(), + KeepPDF: &keep, + Advertise: &adv, + EmailEnabled: &emailOff, + AirDropEnabled: &airDropOff, + EmailTo: DefaultEmailTo, + SMTPHost: DefaultSMTPHost, + SMTPPort: 587, + SMTPTLS: "starttls", + SMTPUser: DefaultEmailTo, } } @@ -276,6 +285,10 @@ func mergeConfig(dst *Config, src Config) { if strings.TrimSpace(src.SMTPUser) != "" { dst.SMTPUser = strings.TrimSpace(src.SMTPUser) } + if src.AirDropEnabled != nil { + v := *src.AirDropEnabled + dst.AirDropEnabled = &v + } } func (c *Config) normalize() error { @@ -309,6 +322,10 @@ func (c *Config) normalize() error { f := false c.EmailEnabled = &f } + if c.AirDropEnabled == nil { + f := false + c.AirDropEnabled = &f + } if strings.TrimSpace(c.EmailTo) == "" { c.EmailTo = DefaultEmailTo } @@ -338,6 +355,7 @@ func ExampleJSON(dataDir string) []byte { adv := true public := false emailOff := false + airDropOff := false c := Config{ Name: "Verae Timestamp", Port: 631, @@ -349,6 +367,7 @@ func ExampleJSON(dataDir string) []byte { Advertise: &adv, AllowPublic: &public, EmailEnabled: &emailOff, + AirDropEnabled: &airDropOff, EmailTo: DefaultEmailTo, SMTPHost: DefaultSMTPHost, SMTPPort: 587, diff --git a/internal/netprint/control.go b/internal/netprint/control.go index 17a0076..180d8e0 100644 --- a/internal/netprint/control.go +++ b/internal/netprint/control.go @@ -9,6 +9,7 @@ import ( "net" "net/http" "net/url" + "runtime" "strings" "time" @@ -46,6 +47,9 @@ type Status struct { SMTPUser string `json:"smtpUser"` SMTPPasswordSet bool `json:"smtpPasswordSet"` ReceiptsPINSet bool `json:"receiptsPinSet"` + Advertise bool `json:"advertise"` + AirDropEnabled bool `json:"airDropEnabled"` + SupportsAirDrop bool `json:"supportsAirDrop"` } // ServiceControl is how the service asks the OS to start, stop, or launch at login. @@ -127,6 +131,16 @@ func (c *Client) SetEmailEnabled(on bool) error { return c.put("/api/v1/settings", map[string]bool{"emailEnabled": on}, nil) } +// SetAdvertise turns Bonjour advertising on or off without restarting IPP. +func (c *Client) SetAdvertise(on bool) error { + return c.put("/api/v1/settings", map[string]bool{"advertise": on}, nil) +} + +// SetAirDropEnabled turns the macOS AirDrop-after-job option on or off. +func (c *Client) SetAirDropEnabled(on bool) error { + return c.put("/api/v1/settings", map[string]bool{"airDropEnabled": on}, nil) +} + // Auth asks the service to store an xVerae- signing key. func (c *Client) Auth(apiKey string) error { return c.post("/api/v1/auth", map[string]string{"apiKey": apiKey}, nil) @@ -230,6 +244,9 @@ func (s *Server) status() Status { SMTPUser: cfg.SMTPUser, SMTPPasswordSet: passwordSet, ReceiptsPINSet: pinSet, + Advertise: cfg.MDNS(), + AirDropEnabled: cfg.AirDropOn(), + SupportsAirDrop: runtime.GOOS == "darwin", } } @@ -361,6 +378,8 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { ClearPassword *bool `json:"clearPassword"` ReceiptsPIN *string `json:"receiptsPin"` ClearPIN *bool `json:"clearPin"` + Advertise *bool `json:"advertise"` + AirDropEnabled *bool `json:"airDropEnabled"` } if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil { http.Error(w, "bad settings", http.StatusBadRequest) @@ -386,6 +405,14 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { v := *body.EmailEnabled cfg.EmailEnabled = &v } + if body.Advertise != nil { + v := *body.Advertise + cfg.Advertise = &v + } + if body.AirDropEnabled != nil { + v := *body.AirDropEnabled + cfg.AirDropEnabled = &v + } if body.EmailTo != nil { cfg.EmailTo = strings.TrimSpace(*body.EmailTo) } @@ -426,7 +453,8 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { applyFirewall(*body.AllowPublic) log.Printf("netprint: firewall public networks=%v", *body.AllowPublic) } - log.Printf("netprint: settings saved name=%q email=%v", cfg.Name, cfg.EmailOn()) + log.Printf("netprint: settings saved name=%q email=%v advertise=%v airdrop=%v", cfg.Name, cfg.EmailOn(), cfg.MDNS(), cfg.AirDropOn()) + s.syncAdvertiser() writeJSON(w, map[string]any{"ok": true, "restart": "port changes apply after restart"}) } @@ -507,6 +535,12 @@ func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) { cfg.AllowPublic = &pub emailOn := r.FormValue("emailEnabled") == "1" cfg.EmailEnabled = &emailOn + if runtime.GOOS == "darwin" { + adv := r.FormValue("advertise") == "1" + cfg.Advertise = &adv + air := r.FormValue("airDropEnabled") == "1" + cfg.AirDropEnabled = &air + } cfg.EmailTo = strings.TrimSpace(r.FormValue("emailTo")) cfg.SMTPHost = strings.TrimSpace(r.FormValue("smtpHost")) if smtpPort > 0 && smtpPort < 65536 { @@ -549,7 +583,8 @@ func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) { return } } - log.Printf("netprint: settings saved name=%q email=%v", cfg.Name, cfg.EmailOn()) + log.Printf("netprint: settings saved name=%q email=%v advertise=%v airdrop=%v", cfg.Name, cfg.EmailOn(), cfg.MDNS(), cfg.AirDropOn()) + s.syncAdvertiser() http.Redirect(w, r, "/settings?saved=1", http.StatusSeeOther) return } @@ -564,6 +599,15 @@ func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) { if strings.TrimSpace(sec.ReceiptsPIN) != "" { pinNote = "A PIN is saved. Leave the field blank to keep it." } + mac := "" + if runtime.GOOS == "darwin" { + mac = fmt.Sprintf(`

Mac

+ +

mDNSResponder registers _ipp._tcp with the AirPrint _universal and IPP Everywhere _print subtypes. macOS asks once for Local Network permission for verae-print. Allow it.

+ +

The menu-bar app, running in your login session, opens the AirDrop share sheet with the stamped certificate and the receipt JSON. The root daemon does not.

`, + checkedAttr(cfg.MDNS()), checkedAttr(cfg.AirDropOn())) + } var b strings.Builder b.WriteString("Verae network printer") b.WriteString("") @@ -595,12 +639,13 @@ func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) {

%s

+%s

Receipts on this computer

`, htmlEscape(cfg.Name), htmlEscape(cfg.ForwardPrinter), cfg.Port, checkedAttr(cfg.PublicNetworks()), checkedAttr(cfg.EmailOn()), htmlEscape(cfg.EmailTo), htmlEscape(cfg.SMTPHost), cfg.SMTPPort, selectedAttr(cfg.SMTPTLS != "tls"), selectedAttr(cfg.SMTPTLS == "tls"), htmlEscape(cfg.SMTPUser), - htmlEscape(pwNote), htmlEscape(pinNote)) + htmlEscape(pwNote), htmlEscape(pinNote), mac) w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write([]byte(b.String())) } diff --git a/internal/netprint/dnsservice.go b/internal/netprint/dnsservice.go index 62966f8..3a7e959 100644 --- a/internal/netprint/dnsservice.go +++ b/internal/netprint/dnsservice.go @@ -9,9 +9,17 @@ import ( "time" ) -// dnsSDInstance is one DNS-SD registration (primary service or a subtype). +// dnsSDInstance is one DNS-SD registration. +// On Windows, InstanceName is the full "._ipp._tcp.local" and RegType +// is empty: DnsServiceRegister cannot express a subtype. +// On macOS, Name is the instance label and RegType is the dns_sd service type, +// including subtypes ("_ipp._tcp,_universal,_print"). mDNSResponder publishes +// the subtype PTRs aimed at Name._ipp._tcp.local. Host is empty so +// mDNSResponder fills in this Mac's own hostname and addresses. type dnsSDInstance struct { InstanceName string + Name string + RegType string Host string Port int IPv4 []net.IP @@ -45,6 +53,39 @@ func serviceToDNS(s Service) []dnsSDInstance { }} } +// serviceToDarwinDNS is the registration set for mDNSResponder. +// One IPP registration carries both AirPrint subtypes. A second registration +// advertises the stamp API. Neither name contains "._sub.", which would make +// the PTR target the subtype name instead of the primary instance. +func serviceToDarwinDNS(s Service) []dnsSDInstance { + name := sanitizeLabel(s.Instance) + return []dnsSDInstance{ + { + InstanceName: name + "._ipp._tcp.local", + Name: name, + RegType: "_ipp._tcp,_universal,_print", + Port: s.Port, + TXT: append([]string(nil), s.TXT...), + }, + { + InstanceName: name + "._verae._tcp.local", + Name: name, + RegType: "_verae._tcp", + Port: s.Port, + TXT: s.veraeTXT(), + }, + } +} + +// dnsPort is the port in network byte order, which DNSServiceRegister requires. +func dnsPort(p int) uint16 { + if p < 0 || p > 65535 { + return 0 + } + v := uint16(p) + return v<<8 | v>>8 +} + // advertiseWithDNS publishes the primary _ipp._tcp name through DnsServiceRegister // (or a test double) and blocks until ctx is canceled. func advertiseWithDNS(ctx context.Context, current func() Service, api dnsSDClient, sink mdnsSink) error { @@ -88,10 +129,77 @@ func advertiseWithDNS(ctx context.Context, current func() Service, api dnsSDClie // socket advertises the full set so plain browse still works. The socket sets // SO_REUSEADDR and joins the mDNS groups so it shares the port with the // Windows stack instead of replacing it. +// +// macOS mDNSResponder accepts subtypes in the regtype +// ("_ipp._tcp,_universal,_print"). Those PTRs target the primary instance. +// The UDP socket stays closed while that registration is up, so the Mac does +// not advertise a second copy. func advertiseForOS(goos string, ctx context.Context, current func() Service, api dnsSDClient, sink mdnsSink) error { - if goos != "windows" { + switch goos { + case "windows": + return advertiseWindows(ctx, current, api, sink) + case "darwin": + return advertiseDarwinDNS(ctx, current, api, sink) + default: return advertiseUDP(ctx, current, sink) } +} + +func advertiseDarwinDNS(ctx context.Context, current func() Service, api dnsSDClient, sink mdnsSink) error { + if api == nil { + return fmt.Errorf("DNSServiceRegister client is nil") + } + insts := serviceToDarwinDNS(current()) + if len(insts) == 0 { + return fmt.Errorf("DNSServiceRegister: empty service") + } + var ok []dnsSDInstance + var veraeErr error + for i, inst := range insts { + if err := api.Register(inst); err != nil { + if i == 0 { + for _, prev := range ok { + _ = api.Deregister(prev) + } + log.Printf("netprint: DNSServiceRegister %s: %v", inst.RegType, err) + udpErr := advertiseUDP(ctx, current, sink) + if udpErr != nil && ctx.Err() == nil { + return fmt.Errorf("DNSServiceRegister %s: %w; shared UDP 5353: %v", inst.RegType, err, udpErr) + } + return nil + } + veraeErr = err + log.Printf("netprint: DNSServiceRegister %s: %v", inst.RegType, err) + continue + } + ok = append(ok, inst) + } + detail := fmt.Sprintf("DNSServiceRegister %s regtype %s port %d", ok[0].InstanceName, ok[0].RegType, ok[0].Port) + if veraeErr != nil { + detail += "; _verae._tcp not registered" + } else if len(ok) > 1 { + detail += "; _verae._tcp" + } + log.Printf("netprint: mDNS %s", detail) + if sink != nil { + sink(mdnsReport{ + Advertising: true, + Mode: "dns-sd", + Detail: detail, + LastSent: time.Now(), + }) + } + <-ctx.Done() + for _, inst := range ok { + if err := api.Deregister(inst); err != nil { + log.Printf("netprint: DNSServiceRefDeallocate %s: %v", inst.InstanceName, err) + } + } + log.Printf("netprint: mDNS DNSServiceRegister removed") + return nil +} + +func advertiseWindows(ctx context.Context, current func() Service, api dnsSDClient, sink mdnsSink) error { var mu sync.Mutex st := struct { dnsOK bool diff --git a/internal/netprint/dnsservice_darwin.go b/internal/netprint/dnsservice_darwin.go new file mode 100644 index 0000000..dd00460 --- /dev/null +++ b/internal/netprint/dnsservice_darwin.go @@ -0,0 +1,131 @@ +//go:build darwin + +package netprint + +import ( + "fmt" + "log" + "runtime" + "sync" + "unsafe" + + "github.com/ebitengine/purego" +) + +// darwinDNS registers with mDNSResponder through DNSServiceRegister. +// Subtypes are part of the regtype. The call does not use cgo; libSystem is +// loaded when the process runs on a Mac. +type darwinDNS struct { + mu sync.Mutex + refs []uintptr + stop []func() +} + +func newDarwinDNS() dnsSDClient { return &darwinDNS{} } + +var ( + dnsOnce sync.Once + dnsErr error + + dnsRegister func(sdRef *uintptr, flags uint32, ifIndex uint32, name, regtype, domain, host *byte, port uint16, txtLen uint16, txtRecord unsafe.Pointer, callback uintptr, context unsafe.Pointer) int32 + dnsProcess func(sdRef uintptr) int32 + dnsDeallocate func(sdRef uintptr) +) + +func loadDNSService() error { + dnsOnce.Do(func() { + lib, err := purego.Dlopen("/usr/lib/libSystem.B.dylib", 1) + if err != nil { + dnsErr = err + return + } + purego.RegisterLibFunc(&dnsRegister, lib, "DNSServiceRegister") + purego.RegisterLibFunc(&dnsProcess, lib, "DNSServiceProcessResult") + purego.RegisterLibFunc(&dnsDeallocate, lib, "DNSServiceRefDeallocate") + }) + return dnsErr +} + +func (d *darwinDNS) Register(inst dnsSDInstance) error { + if err := loadDNSService(); err != nil { + return fmt.Errorf("DNSServiceRegister: %w", err) + } + name := cString(inst.Name) + regtype := cString(inst.RegType) + domain := cString("local") + var host *byte + hostBuf := []byte{} + if inst.Host != "" { + hostBuf = append([]byte(inst.Host), 0) + host = &hostBuf[0] + } + txt := encodeDNSTXT(inst.TXT) + var txtPtr unsafe.Pointer + if len(txt) > 0 { + txtPtr = unsafe.Pointer(&txt[0]) + } + var ref uintptr + rc := dnsRegister(&ref, 0, 0, name, regtype, domain, host, dnsPort(inst.Port), uint16(len(txt)), txtPtr, 0, nil) + runtime.KeepAlive(name) + runtime.KeepAlive(regtype) + runtime.KeepAlive(domain) + runtime.KeepAlive(hostBuf) + runtime.KeepAlive(txt) + if rc != 0 { + return fmt.Errorf("DNSServiceRegister %s %s: code %d", inst.Name, inst.RegType, rc) + } + done := make(chan struct{}) + go func() { + defer close(done) + for { + if err := dnsProcess(ref); err != 0 { + return + } + } + }() + d.mu.Lock() + d.refs = append(d.refs, ref) + d.stop = append(d.stop, func() { + dnsDeallocate(ref) + <-done + }) + d.mu.Unlock() + log.Printf("netprint: DNSServiceRegister %s %s port %d", inst.Name, inst.RegType, inst.Port) + return nil +} + +func (d *darwinDNS) Deregister(inst dnsSDInstance) error { + d.mu.Lock() + if len(d.stop) == 0 { + d.mu.Unlock() + return nil + } + stop := d.stop[0] + d.stop = d.stop[1:] + if len(d.refs) > 0 { + d.refs = d.refs[1:] + } + d.mu.Unlock() + stop() + return nil +} + +func cString(s string) *byte { + b := append([]byte(s), 0) + return &b[0] +} + +func encodeDNSTXT(txt []string) []byte { + var b []byte + for _, s := range txt { + if s == "" { + continue + } + if len(s) > 255 { + s = s[:255] + } + b = append(b, byte(len(s))) + b = append(b, s...) + } + return b +} diff --git a/internal/netprint/events.go b/internal/netprint/events.go new file mode 100644 index 0000000..b30118b --- /dev/null +++ b/internal/netprint/events.go @@ -0,0 +1,201 @@ +package netprint + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "strconv" + "strings" + "sync" + "time" +) + +// JobEvent is one completed job, told to the menu-bar app over the loopback +// control API. The daemon does not open a share sheet. +type JobEvent struct { + Seq int64 `json:"seq"` + Kind string `json:"kind"` + ID int `json:"id"` + ReceiptID string `json:"receiptId"` + Title string `json:"title"` + AirDrop bool `json:"airdrop"` + CertificateURL string `json:"certificateUrl"` + ReceiptJSONURL string `json:"receiptJsonUrl"` + At time.Time `json:"at"` +} + +type jobBus struct { + mu sync.Mutex + next int64 + recent []JobEvent + subs map[chan JobEvent]struct{} + acked map[int]bool +} + +func (s *Server) afterJob(rec Record) { + s.queueReceiptMail(rec) + s.publishJobEvent(rec) +} + +func (s *Server) publishJobEvent(rec Record) { + if rec.State != "completed" { + return + } + base := strings.TrimRight(s.controlURL, "/") + ev := JobEvent{ + Kind: "completed", + ID: rec.ID, + ReceiptID: rec.ReceiptID, + Title: rec.Title, + AirDrop: s.snapshot().AirDropOn(), + CertificateURL: fmt.Sprintf("%s/jobs/%d/receipt.pdf", base, rec.ID), + ReceiptJSONURL: fmt.Sprintf("%s/jobs/%d/receipt.json", base, rec.ID), + At: time.Now(), + } + s.jobs.publish(ev) + if ev.AirDrop { + log.Printf("netprint: job %d airdrop queued receipt=%s", rec.ID, rec.ReceiptID) + } +} + +func (b *jobBus) publish(ev JobEvent) { + b.mu.Lock() + if b.subs == nil { + b.subs = map[chan JobEvent]struct{}{} + } + if b.acked == nil { + b.acked = map[int]bool{} + } + b.next++ + ev.Seq = b.next + b.recent = append(b.recent, ev) + if len(b.recent) > 16 { + b.recent = b.recent[len(b.recent)-16:] + } + subs := make([]chan JobEvent, 0, len(b.subs)) + for ch := range b.subs { + subs = append(subs, ch) + } + b.mu.Unlock() + for _, ch := range subs { + select { + case ch <- ev: + default: + } + } +} + +func (b *jobBus) subscribe(after int64) (backlog []JobEvent, ch chan JobEvent) { + b.mu.Lock() + defer b.mu.Unlock() + if b.subs == nil { + b.subs = map[chan JobEvent]struct{}{} + } + ch = make(chan JobEvent, 32) + b.subs[ch] = struct{}{} + if after == 0 { + if ev, ok := b.pendingLocked(); ok { + backlog = []JobEvent{ev} + } + return backlog, ch + } + for _, ev := range b.recent { + if ev.Seq > after { + backlog = append(backlog, ev) + } + } + return backlog, ch +} + +func (b *jobBus) unsubscribe(ch chan JobEvent) { + b.mu.Lock() + delete(b.subs, ch) + b.mu.Unlock() +} + +func (b *jobBus) ack(id int) { + b.mu.Lock() + defer b.mu.Unlock() + if b.acked == nil { + b.acked = map[int]bool{} + } + b.acked[id] = true +} + +// pendingLocked is the newest AirDrop job the menu bar has not acknowledged. +// The caller holds b.mu. +func (b *jobBus) pendingLocked() (JobEvent, bool) { + for i := len(b.recent) - 1; i >= 0; i-- { + ev := b.recent[i] + if !ev.AirDrop || b.acked[ev.ID] { + continue + } + if time.Since(ev.At) > 10*time.Minute { + continue + } + return ev, true + } + return JobEvent{}, false +} + +func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + fl, ok := w.(http.Flusher) + if !ok { + http.Error(w, "stream unavailable", http.StatusInternalServerError) + return + } + after, _ := strconv.ParseInt(r.URL.Query().Get("after"), 10, 64) + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Connection", "keep-alive") + backlog, ch := s.jobs.subscribe(after) + defer s.jobs.unsubscribe(ch) + for _, ev := range backlog { + writeJobEvent(w, ev) + } + fmt.Fprintf(w, ": ok\n\n") + fl.Flush() + tick := time.NewTicker(15 * time.Second) + defer tick.Stop() + for { + select { + case <-r.Context().Done(): + return + case ev := <-ch: + writeJobEvent(w, ev) + fl.Flush() + case <-tick.C: + fmt.Fprintf(w, ": ping\n\n") + fl.Flush() + } + } +} + +func writeJobEvent(w http.ResponseWriter, ev JobEvent) { + b, err := json.Marshal(ev) + if err != nil { + return + } + fmt.Fprintf(w, "event: job\ndata: %s\n\n", b) +} + +func (s *Server) handleAirDropAck(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var body struct { + ID int `json:"id"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body); err != nil || body.ID <= 0 { + http.Error(w, "bad id", http.StatusBadRequest) + return + } + s.jobs.ack(body.ID) + writeJSON(w, map[string]bool{"ok": true}) +} diff --git a/internal/netprint/server.go b/internal/netprint/server.go index 4b79ec9..ba76f88 100644 --- a/internal/netprint/server.go +++ b/internal/netprint/server.go @@ -30,8 +30,11 @@ type Server struct { ippSrv *http.Server controlSrv *http.Server mdnsCancel context.CancelFunc + mdnsGen int mdns mdnsReport emailErr string + uuid string + jobs jobBus } // Run loads config, listens, and blocks until ctx is canceled. @@ -92,10 +95,11 @@ func Start(cfg Config, configPath string, stamp StampFunc, ctrl ServiceControl) return nil, fmt.Errorf("control listen: %w", err) } s.controlURL = "http://" + ctlLn.Addr().String() + "/" + s.uuid = uuid s.seedEmailError() s.printer.AdminURL = s.receiptsURL() s.printer.OnDocument = func(id int, format, title, user string, doc []byte) { - ProcessJob(s.store, s.snapshot(), s.printer, id, format, title, user, doc, s.stamp, s.forward, s.queueReceiptMail) + ProcessJob(s.store, s.snapshot(), s.printer, id, format, title, user, doc, s.stamp, s.forward, s.afterJob) } admin := &Admin{Store: st, Name: func() string { return s.snapshot().Name }, Port: func() int { return s.port }} @@ -141,6 +145,8 @@ func Start(cfg Config, configPath string, stamp StampFunc, ctrl ServiceControl) ctrlMux.HandleFunc("/api/v1/firewall", s.handleFirewall) ctrlMux.HandleFunc("/api/v1/settings", s.handleSettings) ctrlMux.HandleFunc("/api/v1/auth", s.handleAuth) + ctrlMux.HandleFunc("/api/v1/events", s.handleEvents) + ctrlMux.HandleFunc("/api/v1/airdrop/ack", s.handleAirDropAck) ctrlMux.HandleFunc("/settings", s.handleSettingsPage) ctrlMux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !loopback(r) { @@ -162,34 +168,7 @@ func Start(cfg Config, configPath string, stamp StampFunc, ctrl ServiceControl) })} go func() { _ = s.ippSrv.Serve(ippLn) }() go func() { _ = s.controlSrv.Serve(ctlLn) }() - if cfg.MDNS() { - ctx, cancel := context.WithCancel(context.Background()) - s.mdnsCancel = cancel - s.setMDNS(mdnsReport{Mode: "starting", Detail: "starting"}) - go func() { - err := startAdvertiser(ctx, func() Service { - c := s.snapshot() - ips := lanIPs() - adminURL := s.printer.AdminURL - stampURL := strings.TrimSuffix(adminURL, "/receipts") + "/api/stamp" - return Service{ - Instance: c.Name, - Host: hostLabel(c.Hostname), - Port: s.port, - IPs: ips, - TXT: AirPrintTXT(c.Name, uuid, adminURL, "", ""), - StampURL: stampURL, - } - }, s.setMDNS) - if err != nil && ctx.Err() == nil { - log.Printf("netprint: mDNS not advertised: %v", err) - s.setMDNS(mdnsReport{Mode: "error", Err: err.Error(), Detail: err.Error()}) - } - }() - } else { - log.Printf("netprint: mDNS disabled") - s.setMDNS(mdnsReport{Mode: "off", Detail: "disabled"}) - } + s.syncAdvertiser() log.Printf("netprint: IPP on %s, control on %s", listenerAddrs(ippLn), ctlLn.Addr()) return s, nil } @@ -200,6 +179,71 @@ func (s *Server) snapshot() Config { return s.cfg } +// syncAdvertiser starts or stops Bonjour to match the Advertise setting. +// IPP keeps running either way. A name or port change still needs a restart +// before mDNSResponder replaces a registration it already accepted. +func (s *Server) syncAdvertiser() { + if s.snapshot().MDNS() { + s.startMDNS() + return + } + s.stopMDNS() +} + +func (s *Server) startMDNS() { + s.mu.Lock() + if s.mdnsCancel != nil { + s.mu.Unlock() + return + } + s.mdnsGen++ + gen := s.mdnsGen + ctx, cancel := context.WithCancel(context.Background()) + s.mdnsCancel = cancel + s.mu.Unlock() + s.setMDNS(mdnsReport{Mode: "starting", Detail: "starting"}) + go func() { + err := startAdvertiser(ctx, func() Service { + c := s.snapshot() + adminURL := "" + if s.printer != nil { + adminURL = s.printer.AdminURL + } + return Service{ + Instance: c.Name, + Host: hostLabel(c.Hostname), + Port: s.port, + IPs: lanIPs(), + TXT: AirPrintTXT(c.Name, s.uuid, adminURL, "", ""), + StampURL: strings.TrimSuffix(adminURL, "/receipts") + "/api/stamp", + } + }, s.setMDNS) + s.mu.Lock() + current := s.mdnsGen + s.mu.Unlock() + if current != gen { + return + } + if err != nil && ctx.Err() == nil { + log.Printf("netprint: mDNS not advertised: %v", err) + s.setMDNS(mdnsReport{Mode: "error", Err: err.Error(), Detail: err.Error()}) + } + }() +} + +func (s *Server) stopMDNS() { + s.mu.Lock() + s.mdnsGen++ + cancel := s.mdnsCancel + s.mdnsCancel = nil + s.mu.Unlock() + if cancel != nil { + cancel() + } + log.Printf("netprint: mDNS disabled") + s.setMDNS(mdnsReport{Mode: "off", Detail: "disabled"}) +} + func (s *Server) setMDNS(r mdnsReport) { s.mu.Lock() s.mdns = r @@ -217,8 +261,13 @@ func (s *Server) ControlURL() string { return s.controlURL } // Shutdown stops listeners and sends an mDNS goodbye. func (s *Server) Shutdown(ctx context.Context) error { - if s.mdnsCancel != nil { - s.mdnsCancel() + s.mu.Lock() + cancel := s.mdnsCancel + s.mdnsCancel = nil + s.mdnsGen++ + s.mu.Unlock() + if cancel != nil { + cancel() } var err error if s.ippSrv != nil { diff --git a/internal/netprint/stamp_api.go b/internal/netprint/stamp_api.go index 070e1d1..2fcf1f4 100644 --- a/internal/netprint/stamp_api.go +++ b/internal/netprint/stamp_api.go @@ -41,7 +41,7 @@ func (s *Server) handleStamp(w http.ResponseWriter, r *http.Request) { } user := stampUser(r.Header.Get("X-Verae-User")) log.Printf("netprint: stamp api job %d title=%q bytes=%d", id, title, len(doc)) - ProcessJob(s.store, s.snapshot(), s.printer, id, format, title, user, doc, s.stamp, s.forward, s.queueReceiptMail) + ProcessJob(s.store, s.snapshot(), s.printer, id, format, title, user, doc, s.stamp, s.forward, s.afterJob) rec, ok := s.store.Get(id) if !ok || rec.State != "completed" || rec.CertificatePDF == "" { msg := "stamp failed" diff --git a/internal/netprint/tray_darwin.go b/internal/netprint/tray_darwin.go index cbf3a7a..300e8b3 100644 --- a/internal/netprint/tray_darwin.go +++ b/internal/netprint/tray_darwin.go @@ -36,15 +36,20 @@ func runTray(ctx context.Context) error { menu.Send(objc.RegisterName("setDelegate:"), trayTarget) item.Send(objc.RegisterName("setMenu:"), menu) rebuildDarwinMenu(menu) + watchCtx, watchCancel := context.WithCancel(ctx) + defer watchCancel() + startAirDropWatch(watchCtx) app.Send(objc.RegisterName("run")) return nil } func registerTrayClass() { - _, _ = objc.RegisterClass("VeraeNetPrintTray", objc.GetClass("NSObject"), nil, nil, []objc.MethodDef{ + methods := []objc.MethodDef{ {Cmd: selOpen, Fn: menuWillOpen}, {Cmd: selTray, Fn: trayAction}, - }) + } + methods = append(methods, airDropMethods()...) + _, _ = objc.RegisterClass("VeraeNetPrintTray", objc.GetClass("NSObject"), nil, nil, methods) } func menuWillOpen(self, _cmd, menu objc.ID) { @@ -67,7 +72,7 @@ func trayAction(self, _cmd, sender objc.ID) { func rebuildDarwinMenu(menu objc.ID) { st := Probe() - items := BuildMenu(st) + items := withAirDropAgain(BuildMenu(st)) trayMu.Lock() trayItems = items trayMu.Unlock() diff --git a/internal/netprint/tray_menu.go b/internal/netprint/tray_menu.go index feafa47..f4c6063 100644 --- a/internal/netprint/tray_menu.go +++ b/internal/netprint/tray_menu.go @@ -6,18 +6,21 @@ import ( ) const ( - idStatus = 1 - idStart = 3 - idStop = 4 - idRestart = 5 - idLaunch = 7 - idTray = 8 - idPublic = 9 - idReceipts = 10 - idSettings = 11 - idEmail = 12 - idQuit = 100 - idRecent = 20 + idStatus = 1 + idStart = 3 + idStop = 4 + idRestart = 5 + idLaunch = 7 + idTray = 8 + idPublic = 9 + idReceipts = 10 + idSettings = 11 + idEmail = 12 + idAirDrop = 13 + idAdvertise = 14 + idAirDropAgain = 15 + idQuit = 100 + idRecent = 20 ) // MenuEntry is one row in the tray or menu bar. The UI layers only display it. @@ -73,9 +76,15 @@ func BuildMenu(st Status) []MenuEntry { if st.SupportsPublic { out = append(out, MenuEntry{ID: idPublic, Label: "Allow Public networks", Enabled: st.Running, Checked: st.AllowPublic, Kind: "public"}) } + if st.SupportsAirDrop { + out = append(out, MenuEntry{ID: idAdvertise, Label: "Advertise on the network", Enabled: st.Running, Checked: st.Advertise, Kind: "advertise"}) + } out = append(out, MenuEntry{Separator: true}, MenuEntry{ID: idEmail, Label: "Email stamped PDFs", Enabled: st.Running, Checked: st.EmailEnabled, Kind: "email"}, ) + if st.SupportsAirDrop { + out = append(out, MenuEntry{ID: idAirDrop, Label: "AirDrop stamped PDF after each job", Enabled: st.Running, Checked: st.AirDropEnabled, Kind: "airdrop"}) + } if st.EmailError != "" { out = append(out, MenuEntry{Label: "Email — " + clipMenu(st.EmailError, 90), Kind: "status"}) } else if st.EmailEnabled { @@ -123,6 +132,9 @@ func discoveryLine(st Status) string { switch st.MDNSMode { case "dns-sd": if st.MDNSAdvertising { + if strings.Contains(st.MDNSDetail, "_ipp._tcp,_universal") { + return "Discovery — advertised (Bonjour _universal and _print)" + } return "Discovery — advertised (Windows DNS-SD)" } case "udp": @@ -199,6 +211,12 @@ func Dispatch(st Status, e MenuEntry) error { return c.SetAllowPublic(!st.AllowPublic) case "email": return c.SetEmailEnabled(!st.EmailEnabled) + case "advertise": + return c.SetAdvertise(!st.Advertise) + case "airdrop": + return c.SetAirDropEnabled(!st.AirDropEnabled) + case "airdrop-again": + return airDropAgain() case "open": return OpenURL(e.URL) case "quit": diff --git a/platform/macos/airdrop/main.swift b/platform/macos/airdrop/main.swift new file mode 100644 index 0000000..b44ab38 --- /dev/null +++ b/platform/macos/airdrop/main.swift @@ -0,0 +1,39 @@ +import AppKit + +// verae-airdrop opens the AirDrop share sheet for the files it is given. +// The menu-bar app launches this helper when it is installed next to +// verae-print. The same NSSharingService call lives in the menu-bar app +// for builds that do not include this helper. +// +// Usage: verae-airdrop certificate.pdf receipt.json + +final class AirDropDelegate: NSObject, NSSharingServiceDelegate { + func sharingService(_ sharingService: NSSharingService, didShareItems items: [Any]) { + exit(0) + } + + func sharingService(_ sharingService: NSSharingService, didFailToShareItems items: [Any], error: Error) { + fputs("AirDrop failed: \(error.localizedDescription)\n", stderr) + exit(1) + } +} + +let paths = Array(CommandLine.arguments.dropFirst()).filter { !$0.isEmpty } +if paths.isEmpty { + fputs("usage: verae-airdrop certificate.pdf [receipt.json]\n", stderr) + exit(2) +} + +let app = NSApplication.shared +app.setActivationPolicy(.accessory) +let delegate = AirDropDelegate() +guard let service = NSSharingService(named: .sendViaAirDrop) else { + fputs("AirDrop sharing service is unavailable\n", stderr) + exit(1) +} +service.delegate = delegate +let urls = paths.map { URL(fileURLWithPath: $0) } +app.activate(ignoringOtherApps: true) +service.perform(withItems: urls) +RunLoop.main.run(until: Date().addingTimeInterval(600)) +exit(0) diff --git a/platform/macos/pkg/build-netprint-pkg.sh b/platform/macos/pkg/build-netprint-pkg.sh index 44e79c1..1b79c45 100755 --- a/platform/macos/pkg/build-netprint-pkg.sh +++ b/platform/macos/pkg/build-netprint-pkg.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash # Standalone Verae Network Printer .pkg. +# Linux CI writes VeraeNetPrint-macos-arm64-.pkg. It is not signed. # On a Mac with pkgbuild this produces an installer. Signing happens only when # signing.env is present (same file as the driver package). Without pkgbuild # (Linux CI) the caller still ships the darwin binaries and this script. @@ -16,14 +17,28 @@ if [[ ! -x "$BIN" && ! -f "$BIN" ]]; then exit 1 fi +ARCH="${PKG_ARCH:-arm64}" STAGE="$OUT/payload" SCRIPTS="$OUT/scripts" rm -rf "$STAGE" "$SCRIPTS" -mkdir -p "$STAGE/Library/Application Support/Verae/NetPrint/bin" "$SCRIPTS" +mkdir -p "$STAGE/Library/Application Support/Verae/NetPrint/bin" \ + "$STAGE/Library/LaunchDaemons" \ + "$STAGE/Library/LaunchAgents" \ + "$SCRIPTS" cp -f "$BIN" "$STAGE/Library/Application Support/Verae/NetPrint/bin/verae-print" chmod 755 "$STAGE/Library/Application Support/Verae/NetPrint/bin/verae-print" cp -f "$ROOT/platform/macos/netprint/com.verae.netprint.plist" "$STAGE/Library/Application Support/Verae/NetPrint/" cp -f "$ROOT/platform/macos/netprint/com.verae.netprint.tray.plist" "$STAGE/Library/Application Support/Verae/NetPrint/" +# The package itself installs the root daemon and the Aqua LaunchAgent. +# postinstall bootstraps both; these copies are what launchd reads. +cp -f "$ROOT/platform/macos/netprint/com.verae.netprint.plist" "$STAGE/Library/LaunchDaemons/com.verae.netprint.plist" +cp -f "$ROOT/platform/macos/netprint/com.verae.netprint.tray.plist" "$STAGE/Library/LaunchAgents/com.verae.netprint.tray.plist" +chmod 644 "$STAGE/Library/LaunchDaemons/com.verae.netprint.plist" "$STAGE/Library/LaunchAgents/com.verae.netprint.tray.plist" +if command -v swiftc >/dev/null 2>&1; then + swiftc -O -o "$STAGE/Library/Application Support/Verae/NetPrint/bin/verae-airdrop" \ + "$ROOT/platform/macos/airdrop/main.swift" + chmod 755 "$STAGE/Library/Application Support/Verae/NetPrint/bin/verae-airdrop" +fi # preinstall runs before the payload replaces verae-print. postinstall # bootstraps the service again. cp -f "$ROOT/platform/macos/netprint/bootout.sh" "$SCRIPTS/bootout.sh" @@ -42,9 +57,12 @@ exec "/Library/Application Support/Verae/NetPrint/bin/verae-print" netprint inst EOF chmod 755 "$SCRIPTS/preinstall" "$SCRIPTS/postinstall" "$SCRIPTS/bootout.sh" +PKG_NAME="VeraeNetPrint-macos-${ARCH}-${VERSION}.pkg" if ! command -v pkgbuild >/dev/null 2>&1; then - echo "pkgbuild not available — payload and scripts are in $OUT" + echo "pkgbuild not available — building unsigned $PKG_NAME" tar -C "$OUT" -czf "$OUT/VeraeNetPrint-macos-payload.tar.gz" payload scripts + "$ROOT/platform/macos/pkg/build-unsigned-pkg.sh" "$STAGE" "$SCRIPTS" "$OUT/$PKG_NAME" "$VERSION" + echo "built $OUT/$PKG_NAME (unsigned)" exit 0 fi @@ -54,7 +72,8 @@ pkgbuild \ --identifier "ai.verae.netprint.standalone" \ --version "$VERSION" \ --install-location "/" \ - "$OUT/VeraeNetPrint-macos.pkg" + "$OUT/$PKG_NAME" +cp -f "$OUT/$PKG_NAME" "$OUT/VeraeNetPrint-macos.pkg" SIGNING_ENV="${SIGNING_ENV:-$HOME/fie/installers/macos/signing.env}" if [[ -f "$SIGNING_ENV" ]]; then @@ -63,6 +82,7 @@ if [[ -f "$SIGNING_ENV" ]]; then ID_PKG="${APPLE_INSTALLER_SIGN_ID:-Developer ID Installer: FlightLookup Inc. (G8CKY7VMZC)}" productbuild --package "$OUT/VeraeNetPrint-macos.pkg" --sign "$ID_PKG" --timestamp \ "$OUT/VeraeNetPrint-macos-signed.pkg" - mv -f "$OUT/VeraeNetPrint-macos-signed.pkg" "$OUT/VeraeNetPrint-macos.pkg" + mv -f "$OUT/VeraeNetPrint-macos-signed.pkg" "$OUT/$PKG_NAME" + cp -f "$OUT/$PKG_NAME" "$OUT/VeraeNetPrint-macos.pkg" fi -echo "built $OUT/VeraeNetPrint-macos.pkg" +echo "built $OUT/$PKG_NAME" diff --git a/platform/macos/pkg/build-unsigned-pkg.sh b/platform/macos/pkg/build-unsigned-pkg.sh new file mode 100755 index 0000000..2a01fe3 --- /dev/null +++ b/platform/macos/pkg/build-unsigned-pkg.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Build an unsigned flat package on Linux. pkgbuild is not here. +# The result installs with: sudo installer -pkg VeraeNetPrint-macos-arm64-*.pkg -target / +# Finder: right-click the pkg and choose Open. It is not signed or notarized. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +STAGE="${1:?payload root}" +SCRIPTS="${2:?scripts dir}" +DEST="${3:?output pkg}" +VERSION="${4:?version}" + +if ! command -v cpio >/dev/null 2>&1; then + echo "cpio is required to build the macOS package" >&2 + exit 1 +fi + +MKBOM="${MKBOM:-}" +if [[ -z "$MKBOM" ]]; then + if command -v mkbom >/dev/null 2>&1 && mkbom -h >/dev/null 2>&1; then + MKBOM="$(command -v mkbom)" + fi +fi +if [[ -z "$MKBOM" || ! -x "$MKBOM" ]]; then + TOOL="$ROOT/build/tool" + mkdir -p "$TOOL" + if [[ ! -x "$TOOL/bomutils/build/bin/mkbom" ]]; then + rm -rf "$TOOL/bomutils" + git clone --depth 1 https://github.com/hogliux/bomutils.git "$TOOL/bomutils" + make -C "$TOOL/bomutils" all + fi + MKBOM="$TOOL/bomutils/build/bin/mkbom" +fi + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +COMP="$WORK/netprint.pkg" +mkdir -p "$COMP" + +# odc cpio, root:admin (0:80), matching the Bom. GNU cpio accepts --owner. +( cd "$STAGE" && find . | sort | cpio -o --format odc --owner 0:80 | gzip -c ) > "$COMP/Payload" +( cd "$SCRIPTS" && find . | sort | cpio -o --format odc --owner 0:80 | gzip -c ) > "$COMP/Scripts" +"$MKBOM" -u 0 -g 80 "$STAGE" "$COMP/Bom" + +FILES="$(find "$STAGE" | wc -l | tr -d ' ')" +KB="$(du -sk "$STAGE" | awk '{print $1}')" +cat >"$COMP/PackageInfo" < + + + + + + + +EOF + +cat >"$WORK/Distribution" < + + Verae Network Printer + + + + + + + + + netprint.pkg + +EOF + +( + cd "$ROOT" + go run ./platform/macos/pkg/cmd/mkxar -o "$DEST" "$WORK" +) +echo "unsigned package $DEST" diff --git a/platform/macos/pkg/cmd/mkxar/main.go b/platform/macos/pkg/cmd/mkxar/main.go new file mode 100644 index 0000000..bd36f2c --- /dev/null +++ b/platform/macos/pkg/cmd/mkxar/main.go @@ -0,0 +1,22 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/VeraeLLC/PrintToVeraeTimeStamp/platform/macos/pkg/mkxar" +) + +func main() { + out := flag.String("o", "", "output xar path") + flag.Parse() + if *out == "" || flag.NArg() != 1 { + fmt.Fprintln(os.Stderr, "usage: mkxar -o out.pkg directory") + os.Exit(2) + } + if err := mkxar.WriteDir(*out, flag.Arg(0)); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/platform/macos/pkg/mkxar/xar.go b/platform/macos/pkg/mkxar/xar.go new file mode 100644 index 0000000..7ca972f --- /dev/null +++ b/platform/macos/pkg/mkxar/xar.go @@ -0,0 +1,208 @@ +// Package mkxar writes an uncompressed xar archive. macOS flat packages are +// xar archives whose members are stored, not gzip-compressed, because the +// Payload inside is already gzip. +package mkxar + +import ( + "bytes" + "compress/zlib" + "crypto/sha1" + "encoding/binary" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" +) + +type fileRec struct { + id int + name string + isDir bool + mode os.FileMode + data []byte + kids []*fileRec + offset int +} + +// WriteDir writes root's children as the top level of a xar archive. +func WriteDir(dst string, root string) error { + entries, err := readTree(root) + if err != nil { + return err + } + var files []*fileRec + id := 1 + var walk func(list []*fileRec) + walk = func(list []*fileRec) { + for _, e := range list { + e.id = id + id++ + files = append(files, e) + walk(e.kids) + } + } + walk(entries) + + heap := []byte{} + // 20-byte SHA-1 of the TOC is filled in after the TOC exists. + heap = append(heap, make([]byte, 20)...) + for _, e := range files { + if e.isDir { + continue + } + e.offset = len(heap) + heap = append(heap, e.data...) + } + + toc := buildTOC(entries) + sum := sha1.Sum([]byte(toc)) + copy(heap[:20], sum[:]) + + var zbuf bytes.Buffer + zw, err := zlib.NewWriterLevel(&zbuf, zlib.BestSpeed) + if err != nil { + return err + } + if _, err := zw.Write([]byte(toc)); err != nil { + return err + } + if err := zw.Close(); err != nil { + return err + } + compressed := zbuf.Bytes() + + var hdr [28]byte + binary.BigEndian.PutUint32(hdr[0:4], 0x78617221) + binary.BigEndian.PutUint16(hdr[4:6], 28) + binary.BigEndian.PutUint16(hdr[6:8], 1) + binary.BigEndian.PutUint64(hdr[8:16], uint64(len(compressed))) + binary.BigEndian.PutUint64(hdr[16:24], uint64(len(toc))) + binary.BigEndian.PutUint32(hdr[24:28], 1) // sha1 + + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + if _, err := out.Write(hdr[:]); err != nil { + return err + } + if _, err := out.Write(compressed); err != nil { + return err + } + _, err = out.Write(heap) + return err +} + +func readTree(dir string) ([]*fileRec, error) { + ents, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + sort.Slice(ents, func(i, j int) bool { return ents[i].Name() < ents[j].Name() }) + var out []*fileRec + for _, ent := range ents { + info, err := ent.Info() + if err != nil { + return nil, err + } + rec := &fileRec{name: ent.Name(), mode: info.Mode()} + path := filepath.Join(dir, ent.Name()) + if ent.IsDir() { + rec.isDir = true + rec.kids, err = readTree(path) + if err != nil { + return nil, err + } + } else { + rec.data, err = os.ReadFile(path) + if err != nil { + return nil, err + } + } + out = append(out, rec) + } + return out, nil +} + +func buildTOC(entries []*fileRec) string { + var b strings.Builder + b.WriteString(`` + "\n") + b.WriteString("\n \n") + b.WriteString(" \n 0\n 20\n \n") + for _, e := range entries { + writeFile(&b, e, 1) + } + b.WriteString(" \n\n") + return b.String() +} + +func writeFile(b *strings.Builder, e *fileRec, depth int) { + pad := strings.Repeat(" ", depth) + fmt.Fprintf(b, "%s\n", pad, e.id) + fmt.Fprintf(b, "%s %s\n", pad, xmlEscape(e.name)) + if e.isDir { + fmt.Fprintf(b, "%s directory\n", pad) + } else { + fmt.Fprintf(b, "%s file\n", pad) + } + fmt.Fprintf(b, "%s %04o\n", pad, e.mode.Perm()) + fmt.Fprintf(b, "%s 0\n%s 0\n", pad, pad) + if !e.isDir { + sum := sha1.Sum(e.data) + hexSum := hex.EncodeToString(sum[:]) + fmt.Fprintf(b, "%s \n", pad) + fmt.Fprintf(b, "%s %d\n", pad, e.offset) + fmt.Fprintf(b, "%s %d\n", pad, len(e.data)) + fmt.Fprintf(b, "%s %d\n", pad, len(e.data)) + fmt.Fprintf(b, "%s \n", pad) + fmt.Fprintf(b, "%s %s\n", pad, hexSum) + fmt.Fprintf(b, "%s %s\n", pad, hexSum) + fmt.Fprintf(b, "%s \n", pad) + } + for _, k := range e.kids { + writeFile(b, k, depth+1) + } + fmt.Fprintf(b, "%s\n", pad) +} + +func xmlEscape(s string) string { + r := strings.NewReplacer("&", "&", "<", "<", ">", ">") + return r.Replace(s) +} + +// TOC extracts the uncompressed table of contents. Tests use it. +func TOC(r io.ReaderAt, size int64) (string, error) { + if size < 28 { + return "", fmt.Errorf("short xar") + } + var hdr [28]byte + if _, err := r.ReadAt(hdr[:], 0); err != nil { + return "", err + } + if binary.BigEndian.Uint32(hdr[0:4]) != 0x78617221 { + return "", fmt.Errorf("not a xar") + } + comp := binary.BigEndian.Uint64(hdr[8:16]) + rawLen := binary.BigEndian.Uint64(hdr[16:24]) + buf := make([]byte, comp) + if _, err := r.ReadAt(buf, 28); err != nil { + return "", err + } + zr, err := zlib.NewReader(bytes.NewReader(buf)) + if err != nil { + return "", err + } + defer zr.Close() + out, err := io.ReadAll(zr) + if err != nil { + return "", err + } + if uint64(len(out)) != rawLen { + return "", fmt.Errorf("toc length %d want %d", len(out), rawLen) + } + return string(out), nil +} diff --git a/platform/macos/pkg/mkxar/xar_test.go b/platform/macos/pkg/mkxar/xar_test.go new file mode 100644 index 0000000..194ae93 --- /dev/null +++ b/platform/macos/pkg/mkxar/xar_test.go @@ -0,0 +1,52 @@ +package mkxar + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWriteDirRoundTrip(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "netprint.pkg"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "Distribution"), []byte(""), 0o644); err != nil { + t.Fatal(err) + } + payload := bytesRepeat(1000) + if err := os.WriteFile(filepath.Join(root, "netprint.pkg", "Payload"), payload, 0o644); err != nil { + t.Fatal(err) + } + dst := filepath.Join(t.TempDir(), "out.pkg") + if err := WriteDir(dst, root); err != nil { + t.Fatal(err) + } + f, err := os.Open(dst) + if err != nil { + t.Fatal(err) + } + defer f.Close() + st, err := f.Stat() + if err != nil { + t.Fatal(err) + } + toc, err := TOC(f, st.Size()) + if err != nil { + t.Fatal(err) + } + for _, needle := range []string{"Distribution", "netprint.pkg", "Payload", "application/octet-stream"} { + if !strings.Contains(toc, needle) { + t.Fatalf("toc missing %s\n%s", needle, toc) + } + } +} + +func bytesRepeat(n int) []byte { + b := make([]byte, n) + for i := range b { + b[i] = byte(i) + } + return b +} diff --git a/scripts/build-netprint-installers.sh b/scripts/build-netprint-installers.sh index c842974..d880655 100755 --- a/scripts/build-netprint-installers.sh +++ b/scripts/build-netprint-installers.sh @@ -78,14 +78,15 @@ else cp -f "$OUT/darwin-amd64/verae-print" "$OUT/macos/verae-print-amd64" cp -f "$OUT/darwin-arm64/verae-print" "$OUT/macos/verae-print" fi -chmod +x platform/macos/pkg/build-netprint-pkg.sh -BIN="$OUT/macos/verae-print" OUT="$OUT/macos" VERSION="$VERSION" ./platform/macos/pkg/build-netprint-pkg.sh +chmod +x platform/macos/pkg/build-netprint-pkg.sh platform/macos/pkg/build-unsigned-pkg.sh +# Apple Silicon package. The arm64 slice is the binary George runs. +BIN="$OUT/darwin-arm64/verae-print" PKG_ARCH=arm64 OUT="$OUT/macos" VERSION="$VERSION" ./platform/macos/pkg/build-netprint-pkg.sh cp -f platform/macos/pkg/distribution.xml "$OUT/macos/distribution.xml" cp -f platform/macos/netprint/scripts/postinstall "$OUT/macos/postinstall-netprint.sh" ( cd "$OUT" - sha256sum *.deb *.exe "$OUT/macos"/* 2>/dev/null | tee SHA256SUMS || true + sha256sum *.deb *.exe macos/*.pkg macos/*.tar.gz macos/verae-print macos/verae-print-amd64 macos/verae-print-arm64 macos/distribution.xml macos/postinstall-netprint.sh 2>/dev/null | tee SHA256SUMS || true ) echo echo "Artifacts in $OUT"