PrintToVeraeTimeStamp/internal/netprint/airdrop_darwin.go
Cursor Agent f96ade41bd
Ship the Mac menu bar as an app bundle and use one real stamp path.
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>
2026-10-05 02:09:48 +00:00

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