AirDrop stamped PDFs from the macOS menu bar.

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>
This commit is contained in:
Cursor Agent 2026-10-05 01:27:23 +00:00
commit 927229ab4f
No known key found for this signature in database
25 changed files with 2070 additions and 82 deletions

View file

@ -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

View file

@ -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 `<name>._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 `<name>._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 `<name>._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 `<name>._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 `<name>._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://<lan-address>:<port>/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.

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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):
}
}
}

View file

@ -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)
}

View file

@ -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{} }

View file

@ -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)
}
}

View file

@ -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,

View file

@ -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(`<h2>Mac</h2>
<label class="check"><input name="advertise" type="checkbox" value="1" %s> Advertise on the network</label>
<p>mDNSResponder registers <code>_ipp._tcp</code> with the AirPrint <code>_universal</code> and IPP Everywhere <code>_print</code> subtypes. macOS asks once for Local Network permission for verae-print. Allow it.</p>
<label class="check"><input name="airDropEnabled" type="checkbox" value="1" %s> AirDrop stamped PDF after each job</label>
<p>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.</p>`,
checkedAttr(cfg.MDNS()), checkedAttr(cfg.AirDropOn()))
}
var b strings.Builder
b.WriteString("<!doctype html><meta charset=utf-8><meta name=viewport content=\"width=device-width, initial-scale=1\"><title>Verae network printer</title>")
b.WriteString("<style>body{font:16px/1.45 system-ui,sans-serif;max-width:36rem;margin:2rem auto;padding:0 1rem}label{display:block;margin-top:1rem}input,select{width:100%;padding:.4rem;box-sizing:border-box}label.check input{width:auto}</style>")
@ -595,12 +639,13 @@ func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) {
<label>Access PIN<input name="receiptsPin" type="password" autocomplete="off" placeholder="leave blank to keep the saved PIN"></label>
<p>%s</p>
<label class="check"><input name="clearPin" type="checkbox" value="1"> Clear the access PIN</label>
%s
<p><button type="submit">Save</button> <a href="/receipts">Receipts on this computer</a></p>
</form>`,
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()))
}

View file

@ -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 "<name>._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

View file

@ -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
}

201
internal/netprint/events.go Normal file
View file

@ -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})
}

View file

@ -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 {

View file

@ -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"

View file

@ -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()

View file

@ -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":

View file

@ -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)

View file

@ -1,5 +1,6 @@
#!/usr/bin/env bash
# Standalone Verae Network Printer .pkg.
# Linux CI writes VeraeNetPrint-macos-arm64-<version>.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"

Some files were not shown because too many files have changed in this diff Show more