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>
262 lines
6.3 KiB
Go
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):
|
|
}
|
|
}
|
|
}
|