PrintToVeraeTimeStamp/internal/netprint/airdrop.go
Cursor Agent 927229ab4f
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>
2026-10-05 01:27:23 +00:00

262 lines
6.3 KiB
Go

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