The tray and AirDrop helper now live in Verae NetPrint.app so notifications can start, /api/stamp uses the same client as Print-Job and refuses mock receipts, and the default printer name includes the hostname with an mDNS rename when that name is taken. Co-authored-by: George Lambert <marchon@gmail.com>
351 lines
9.1 KiB
Go
351 lines
9.1 KiB
Go
//go:build darwin
|
|
|
|
package netprint
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"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 || !notificationsAllowed() {
|
|
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() {
|
|
// UNUserNotificationCenter throws NSInternalInconsistencyException when
|
|
// mainBundle is a directory (bundleProxyForCurrentProcess is nil). Skip
|
|
// the call unless this process is inside a real .app.
|
|
if !notificationsAllowed() {
|
|
return
|
|
}
|
|
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
|
|
}
|
|
|
|
var noteSkip sync.Once
|
|
|
|
// notificationsAllowed reports whether UserNotifications can be used.
|
|
// currentNotificationCenter crashes when the process is not an app bundle.
|
|
func notificationsAllowed() bool {
|
|
bundle := cls("NSBundle").Send(objc.RegisterName("mainBundle"))
|
|
path := ""
|
|
ident := ""
|
|
if bundle != 0 {
|
|
if url := bundle.Send(objc.RegisterName("bundleURL")); url != 0 {
|
|
path = objcUTF8(url.Send(objc.RegisterName("path")))
|
|
}
|
|
ident = objcUTF8(bundle.Send(objc.RegisterName("bundleIdentifier")))
|
|
}
|
|
if ident != "" && strings.HasSuffix(path, ".app") {
|
|
return true
|
|
}
|
|
noteSkip.Do(func() {
|
|
log.Printf("netprint: notifications skipped; bundleProxyForCurrentProcess is nil (%s)", path)
|
|
})
|
|
return false
|
|
}
|
|
|
|
func registerAirDropCategory() {
|
|
if !notificationsAllowed() {
|
|
notesReady = false
|
|
return
|
|
}
|
|
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)
|
|
}
|