go-notify-darwin

Rich notifications on macOS for Go
Log | Files | Refs | README | LICENSE

commit e4df3816fe180020a9ad1c9916e750cad534c90f
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Wed, 11 Dec 2024 10:45:52 +0800

initial

Signed-off-by: Jack Mordaunt <jackmordaunt.dev@gmail.com>

Diffstat:
A.gitignore | 3+++
AREADME.md | 31+++++++++++++++++++++++++++++++
Aexample/example.app/Contents/Info.plist | 32++++++++++++++++++++++++++++++++
Aexample/example.app/Contents/PkgInfo | 2++
Aexample/example.app/Contents/Resources/icon.icns | 0
Aexample/example.app/Contents/Resources/icon.jpg | 0
Aexample/gen.go | 3+++
Aexample/go.mod | 16++++++++++++++++
Aexample/go.sum | 23+++++++++++++++++++++++
Aexample/go.work | 6++++++
Aexample/go.work.sum | 8++++++++
Aexample/main.go | 134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ago.mod | 3+++
Ago.sum | 0
Anoti.go | 306+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anoti.h | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anoti.m | 304+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
17 files changed, 942 insertions(+), 0 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +_CodeSignature/ +noti diff --git a/README.md b/README.md @@ -0,0 +1,31 @@ +# go-notify-darwin + +This package binds to Apple's UNNotificationn framework in order to create rich notifications. +A large part of the API is surfaced, however no guarantee is made about feature completeness +or API stability. + +## Usage + +Register your categories with `notify.Init`. +Set your callback with `notify.SetCallback`. This will be called upon user interaction. +Push notifications with `notify.Notify`. + +## Conveniences + +In order to make this API convenient it allows you to specify actions within notifications directly. +On the backend an ad-hoc `UNNotificationCategory` is created for that notification. +The traditional method on macOS is to rather register all of your application's notification categories +at init time, which you can do via the `notify.Init` function. + +## Caveats + +For notification interactions to work you must have an `NSApp` running. +The example relies on [Gio](https://gioui.org) for this. + +## Example + +To run the example: + +`cd ./example && go generate . && ./example.app/Contents/MacOS/noti` + + diff --git a/example/example.app/Contents/Info.plist b/example/example.app/Contents/Info.plist @@ -0,0 +1,32 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>CFBundleExecutable</key> + <string>noti</string> + <key>CFBundleIdentifier</key> + <string>org.noti.example</string> + <key>CFBundleInfoDictionaryVersion</key> + <string>6.0</string> + <key>CFBundleName</key> + <string>noti</string> + <key>CFBundleIconFile</key> + <string>icon.icns</string> + <key>CFBundlePackageType</key> + <string>APPL</string> + <key>CFBundleShortVersionString</key> + <string>1.0</string> + <key>CFBundleSupportedPlatforms</key> + <array> + <string>MacOSX</string> + </array> + <key>CFBundleVersion</key> + <string>1</string> + <key>NSPrincipalClass</key> + <string>NSApplication</string> + <key>NSSupportsAutomaticTermination</key> + <true/> + <key>NSSupportsSuddenTermination</key> + <true/> +</dict> +</plist> diff --git a/example/example.app/Contents/PkgInfo b/example/example.app/Contents/PkgInfo @@ -0,0 +1 @@ +APPL???? +\ No newline at end of file diff --git a/example/example.app/Contents/Resources/icon.icns b/example/example.app/Contents/Resources/icon.icns Binary files differ. diff --git a/example/example.app/Contents/Resources/icon.jpg b/example/example.app/Contents/Resources/icon.jpg Binary files differ. diff --git a/example/gen.go b/example/gen.go @@ -0,0 +1,3 @@ +//go:generate go build -o example.app/Contents/MacOS/noti . +//go:generate codesign -f -s - example.app +package main diff --git a/example/go.mod b/example/go.mod @@ -0,0 +1,16 @@ +module git.sr.ht/~jackmordaunt/go-notify-darwin/example + +go 1.23.4 + +require gioui.org v0.7.1 + +require ( + gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 // indirect + gioui.org/shader v1.0.8 // indirect + github.com/go-text/typesetting v0.1.1 // indirect + golang.org/x/exp v0.0.0-20240707233637-46b078467d37 // indirect + golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37 // indirect + golang.org/x/image v0.18.0 // indirect + golang.org/x/sys v0.22.0 // indirect + golang.org/x/text v0.16.0 // indirect +) diff --git a/example/go.sum b/example/go.sum @@ -0,0 +1,23 @@ +eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY= +eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA= +gioui.org v0.7.1 h1:l7OVj47n1z8acaszQ6Wlu+Rxme+HqF3q8b+Fs68+x3w= +gioui.org v0.7.1/go.mod h1:5Kw/q7R1BWc5MKStuTNvhCgSrRqbfHc9Dzfjs4IGgZo= +gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= +gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 h1:AGDDxsJE1RpcXTAxPG2B4jrwVUJGFDjINIPi1jtO6pc= +gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= +gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA= +gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM= +github.com/go-text/typesetting v0.1.1 h1:bGAesCuo85nXnEN5LmFMVGAGpGkCPtHrZLi//qD7EJo= +github.com/go-text/typesetting v0.1.1/go.mod h1:d22AnmeKq/on0HNv73UFriMKc4Ez6EqZAofLhAzpSzI= +github.com/go-text/typesetting-utils v0.0.0-20231211103740-d9332ae51f04 h1:zBx+p/W2aQYtNuyZNcTfinWvXBQwYtDfme051PR/lAY= +github.com/go-text/typesetting-utils v0.0.0-20231211103740-d9332ae51f04/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= +golang.org/x/exp v0.0.0-20240707233637-46b078467d37 h1:uLDX+AfeFCct3a2C7uIWBKMJIR3CJMhcgfrUAqjRK6w= +golang.org/x/exp v0.0.0-20240707233637-46b078467d37/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37 h1:SOSg7+sueresE4IbmmGM60GmlIys+zNX63d6/J4CMtU= +golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37/go.mod h1:3F+MieQB7dRYLTmnncoFbb1crS5lfQoTfDgQy6K4N0o= +golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= +golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= diff --git a/example/go.work b/example/go.work @@ -0,0 +1,6 @@ +go 1.23.4 + +use ( + . + .. +) diff --git a/example/go.work.sum b/example/go.work.sum @@ -0,0 +1,8 @@ +dmitri.shuralyov.com/gpu/mtl v0.0.0-20221208032759-85de2813cf6b/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20231223183121-56fa3ac82ce7/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= +golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a/go.mod h1:Ede7gF0KGoHlj822RtphAHK1jLdrcuRBZg0sF1Q+SPc= +golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= diff --git a/example/main.go b/example/main.go @@ -0,0 +1,134 @@ +package main + +import ( + "log/slog" + "os" + + notify "git.sr.ht/~jackmordaunt/go-notify-darwin" + + "gioui.org/app" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/widget" + "gioui.org/widget/material" +) + +func main() { + slog.SetLogLoggerLevel(slog.LevelDebug) + + notify.Init(notify.Category{ + ID: "best category eva", + Summary: "cool category", + Preview: "wowo cool", + Actions: []notify.Action{ + { + ID: "plain-action", + Title: "Button", + Icon: "icon.png", + }, + }, + TextInputActions: []notify.TextInputAction{ + { + ID: "plain-action-text-input", + Title: "Say Something", + ButtonTitle: "Send Message", + Icon: "icon.jpg", + Placeholder: "type here", + }, + }, + }) + + notify.SetCallback(func(args notify.CallbackArgs) { + slog.Info("callback", "args", args) + }) + + go func() { + os.Exit(run()) + }() + + slog.Info("waiting") + app.Main() +} + +func run() int { + w := new(app.Window) + w.Option(app.Title("gio app")) + + var ( + ops op.Ops + ) + + for { + switch e := w.Event().(type) { + case app.FrameEvent: + gtx := app.NewContext(&ops, e) + frame(gtx) + e.Frame(gtx.Ops) + case app.DestroyEvent: + return 0 + } + } +} + +var ( + notifyButton widget.Clickable + notifyAdhocButton widget.Clickable + cancelButton widget.Clickable + th = material.NewTheme() +) + +type ( + C = layout.Context + D = layout.Dimensions +) + +func frame(gtx layout.Context) layout.Dimensions { + if cancelButton.Clicked(gtx) { + notify.Cancel() + } + + if notifyButton.Clicked(gtx) { + notify.Notify(notify.Notification{ + Title: "title", + Subtitle: "subtitle", + Body: "body", + UserData: map[string]string{"foo": "bar"}, + Attachments: []string{"icon.jpg"}, + Category: "best category eva", + }) + } + + if notifyAdhocButton.Clicked(gtx) { + notify.Notify(notify.Notification{ + Title: "title", + Subtitle: "subtitle", + Body: "body", + Actions: []notify.Action{ + { + ID: "buy", + Title: "Buy", + }, + { + ID: "sell", + Title: "Sell", + }, + }, + }) + } + + return layout.Center.Layout(gtx, func(gtx C) D { + return layout.Flex{}.Layout(gtx, + layout.Rigid(func(gtx C) D { + return material.Button(th, &notifyButton, "notify").Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + return material.Button(th, &notifyAdhocButton, "adhoc").Layout(gtx) + }), + layout.Rigid(layout.Spacer{Width: 8}.Layout), + layout.Rigid(func(gtx C) D { + return material.Button(th, &cancelButton, "cancel").Layout(gtx) + }), + ) + }) +} diff --git a/go.mod b/go.mod @@ -0,0 +1,3 @@ +module git.sr.ht/~jackmordaunt/go-notify-darwin + +go 1.23.4 diff --git a/go.sum b/go.sum diff --git a/noti.go b/noti.go @@ -0,0 +1,306 @@ +//go:build darwin && cgo +// +build darwin,cgo + +package notify + +/* +#cgo LDFLAGS: -framework Foundation -framework UserNotifications +#cgo CFLAGS: -x objective-c -fobjc-arc -fmodules -O3 -Wall -Wextra +#pragma clang diagnostic ignored "-Wformat-security" +#pragma clang diagnostic ignored "-Wunused-variable" +#include <stdlib.h> +#include <stdint.h> +#include "noti.h" +*/ +import "C" + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "runtime" + "sync/atomic" + "unsafe" +) + +func init() { + runtime.LockOSThread() + C.noti_setup() +} + +var ( + // logger is the package logger to use. + logger atomic.Pointer[slog.Logger] + + // callback is the pure Go hook that the application registers in order to receive user + // interactions from notifications. + callback atomic.Pointer[CallbackFunc] +) + +// SetCallback sets the application callback to be invoked upon user interaction +// with a notification. +func SetCallback(cb CallbackFunc) { + callback.Store(&cb) +} + +// SetLogger sets the package logger to use. +func SetLogger(l *slog.Logger) { + logger.Swap(l) +} + +// Init declares the set of notification categories for the application. +func Init(categories ...Category) { + for _, category := range categories { + C.noti_category_register(category.toCType()) + } +} + +// Notify pushes the notification to the system. +// Set a logger to view errors. +func Notify(n Notification) { + C.noti_notify(n.toCType()) +} + +// Cancel notifications for the package. +func Cancel() { + C.noti_cancel() +} + +type ( + CategoryID = string + ActionID = string + UserText = string + UserData = map[string]string +) + +// CallbackArgs contains the data from a user interaction of a notification. +type CallbackArgs struct { + Category CategoryID + Action ActionID + UserData UserData // data attached to the notification by the application + UserText string // text input by the user (applies to text actions) + Err error // any error that occured processing the user data +} + +// CallbackFunc signature. Switch on the action to know how to interpret the dynamic data. +type CallbackFunc func(CallbackArgs) + +// Category defines a set of actions that a notification can reference by ID to +// have them be applied. +type Category struct { + ID CategoryID + Summary string // summarise the category + Preview string // hidden preview body placeholder + Actions []Action + TextInputActions []TextInputAction +} + +// Notification describes +type Notification struct { + Title string + Subtitle string + Body string + + // UserData is app supplied data that is passed back upon user interaction. + UserData UserData + + // Attachments is a list of image names. + // If the image name is an absolute path it will be followed literally. + // If the image name is just a base name, the bundle will be searched for it. + // The named image must exist on disk, must be PNG, JPEG or GIF and must be + // less than 10MB. + Attachments []string + + // Category to apply. If supplied the actions attached on this notification will be + // ignored and the pre-existing category used. + Category CategoryID + + // Actions, if supplied here, will provoke an ad-hoc Category to be created to + // contain them on the native side. This is for convenience. + Actions []Action // button action + TextInputActions []TextInputAction // text input action +} + +// Action is a button action. +type Action struct { + ID ActionID // globally unique, used in callback + Title string + Icon string // name of asset within bundle to use as icon; TODO: could support system defined icons as well +} + +// TextInputAction allows for text input from user. +type TextInputAction struct { + ID ActionID // globally unique, used in callback + Title string + Icon string // name of asset within bundle to use as icon; TODO: could support system defined icons as well + ButtonTitle string + Placeholder string +} + +/* + To keep things simple (dealing with nested objects), we tie the C memory + to the lifetime of the various Go objects by doing the frees in a runtime + finalizer. +*/ + +func (cc *Category) toCType() (c C.Category) { + var p runtime.Pinner + + c.id = C.CString(cc.ID) + c.summary = C.CString(cc.Summary) + c.preview = C.CString(cc.Preview) + + cActions := make([]C.Action, len(cc.Actions)) + cTextInputActions := make([]C.TextInputAction, len(cc.TextInputActions)) + + for ii, action := range cc.Actions { + cActions[ii] = action.toCType() + } + + for ii, action := range cc.TextInputActions { + cTextInputActions[ii] = action.toCType() + } + + c.actions = unsafe.SliceData(cActions) + c.actions_len = C.uintptr_t(len(cActions)) + c.text_input_actions = unsafe.SliceData(cTextInputActions) + c.text_input_actions_len = C.uintptr_t(len(cTextInputActions)) + + runtime.SetFinalizer(cc, func(_ *Category) { + C.free(unsafe.Pointer(c.id)) + C.free(unsafe.Pointer(c.summary)) + C.free(unsafe.Pointer(c.preview)) + p.Unpin() + }) + + p.Pin(unsafe.SliceData(cActions)) + p.Pin(unsafe.SliceData(cTextInputActions)) + + return c +} + +func (n *Notification) toCType() (c C.Notification) { + var p runtime.Pinner + + c.title = C.CString(n.Title) + c.sub_title = C.CString(n.Subtitle) + c.body = C.CString(n.Body) + c.category_id = C.CString(n.Category) + + userDataBytes, err := json.Marshal(n.UserData) + if len(userDataBytes) == 0 || bytes.Equal(userDataBytes, []byte("null")) || err != nil { + c.user_data = C.CString(`"{}"`) + } else { + c.user_data = C.CString(string(userDataBytes)) + } + + cAttachments := make([]*C.char, len(n.Attachments)) + cActions := make([]C.Action, len(n.Actions)) + cTextInputActions := make([]C.TextInputAction, len(n.TextInputActions)) + + for ii, a := range n.Attachments { + cAttachments[ii] = C.CString(a) + } + + for ii, action := range n.Actions { + cActions[ii] = action.toCType() + } + + for ii, action := range n.TextInputActions { + cTextInputActions[ii] = action.toCType() + } + + c.attachments = unsafe.SliceData(cAttachments) + c.attachments_len = C.uintptr_t(len(cAttachments)) + c.actions = unsafe.SliceData(cActions) + c.actions_len = C.uintptr_t(len(cActions)) + c.text_input_actions = unsafe.SliceData(cTextInputActions) + c.text_input_actions_len = C.uintptr_t(len(cTextInputActions)) + + runtime.SetFinalizer(n, func(_ *Notification) { + C.free(unsafe.Pointer(c.title)) + C.free(unsafe.Pointer(c.sub_title)) + C.free(unsafe.Pointer(c.body)) + C.free(unsafe.Pointer(c.category_id)) + C.free(unsafe.Pointer(c.user_data)) + for ii := range cAttachments { + C.free(unsafe.Pointer(cAttachments[ii])) + } + p.Unpin() + }) + + p.Pin(unsafe.SliceData(userDataBytes)) + p.Pin(unsafe.SliceData(cAttachments)) + p.Pin(unsafe.SliceData(cActions)) + p.Pin(unsafe.SliceData(cTextInputActions)) + + return c +} + +func (a *Action) toCType() (c C.Action) { + c.id = C.CString(a.ID) + c.icon = C.CString(a.Icon) + c.title = C.CString(a.Title) + + runtime.SetFinalizer(a, func(_ *Action) { + C.free(unsafe.Pointer(c.id)) + C.free(unsafe.Pointer(c.title)) + C.free(unsafe.Pointer(c.icon)) + }) + + return c +} + +func (a *TextInputAction) toCType() (c C.TextInputAction) { + c.id = C.CString(a.ID) + c.icon = C.CString(a.Icon) + c.title = C.CString(a.Title) + c.button_title = C.CString(a.ButtonTitle) + c.placeholder = C.CString(a.Placeholder) + + runtime.SetFinalizer(a, func(_ *TextInputAction) { + C.free(unsafe.Pointer(c.id)) + C.free(unsafe.Pointer(c.title)) + C.free(unsafe.Pointer(c.icon)) + C.free(unsafe.Pointer(c.button_title)) + C.free(unsafe.Pointer(c.placeholder)) + }) + + return c +} + +//export gocallback +func gocallback( + categoryID *C.cchar_t, + actionID *C.cchar_t, + userText *C.cchar_t, + userData *C.cchar_t, +) { + cb := callback.Load() + + if cb == nil { + return + } + + category := C.GoString(categoryID) + action := C.GoString(actionID) + text := C.GoString(userText) + data := C.GoString(userData) + + var v map[string]string + err := json.Unmarshal([]byte(data), &v) + + (*cb)(CallbackArgs{ + Category: CategoryID(category), + Action: ActionID(action), + UserText: text, + UserData: v, + Err: err, + }) +} + +//export golog +func golog(level C.int, msg *C.cchar_t) { + slog.Log(context.Background(), slog.Level(level), C.GoString(msg)) +} diff --git a/noti.h b/noti.h @@ -0,0 +1,71 @@ +#ifndef GO_NOTI_H +#define GO_NOTI_H + +#include <stdint.h> + +// cchar_t encodes const information such that Go code can declare const char * and avoid +// compiler warnings related to implicitly casting away the const. +typedef const char cchar_t; + +typedef struct Action { + char *id; + char *title; + char *icon; + +} Action; + +typedef struct TextInputAction { + char *id; + char *title; + char *button_title; + char *placeholder; + char *icon; + +} TextInputAction; + +typedef struct Notification { + char *title; + char *sub_title; + char *body; + char *user_data; // JSON encoded data; unmarshal into NSDictionary. + char **attachments; + uintptr_t attachments_len; + + char *category_id; + + // Actions will be used to create an ad-hoc category prior to issuing the notification. + Action *actions; + uintptr_t actions_len; + TextInputAction *text_input_actions; + uintptr_t text_input_actions_len; + +} Notification; + +typedef struct Category { + char *id; + char *summary; + char *preview; + Action *actions; + uintptr_t actions_len; + TextInputAction *text_input_actions; + uintptr_t text_input_actions_len; + +} Category; + +// setup registers the app delegate; called automatically by Go package. +void +noti_setup(); + +// notify pushes a notification. +void +noti_notify(Notification n); + +// noti_category_register adds a notification category. +void +noti_category_register(Category category); + +// cancel removes a pushed notification. +void +noti_cancel(); + +#endif diff --git a/noti.m b/noti.m @@ -0,0 +1,304 @@ +#import <stdlib.h> +#import "_cgo_export.h" + +#if ! __has_feature(objc_arc) + #error This file must be compiled with ARC. Use -fobjc-arc flag (or convert project to ARC). +#endif + +@import Foundation; +@import UserNotifications; + +// Log levels mirror Go's slog package. +const int DEBUG = -4; +const int INFO = 0; +const int WARN = 4; +const int ERROR = 8; + +// notify_log macro provides a var-args wrapper that can do formatting to the Go side log function. +#define notify_log(level, fmt, ...) golog(level, [[NSString stringWithFormat:@fmt, ##__VA_ARGS__] UTF8String]) + +@interface NotifyUNDelegate : NSObject <UNUserNotificationCenterDelegate> +{ } +- (void)userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void (^)(void))completionHandler; +- (void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler; +- (void)userNotificationCenter:(UNUserNotificationCenter *)center openSettingsForNotification:(UNNotification *)notification; +@end + +@implementation NotifyUNDelegate +- (void)userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void (^)(void))completionHandler { + notify_log(DEBUG, "NotifyUNDelegate: didReceiveNotificationResponse %@", response); + + NSError *error; + NSData *jsonData = [NSJSONSerialization dataWithJSONObject: response.notification.request.content.userInfo options: NSJSONWritingPrettyPrinted error: &error]; + NSString *actionID = response.actionIdentifier; + NSString *categoryID = response.notification.request.content.categoryIdentifier; + NSString *userText = @""; + + // NOTE(jfm): macOS passes a different object for text actions (UNTextInputNotificationResponse). + // Since we declare the method to use UNNotificationResponse, we must use reflection to grab the + // userText property when the concrete object is actually UNTextInputNotificationResponse. + // In the case of a non-text action the userText will be the empty string. + + @try { + userText = [response valueForKey:@"userText"]; + } + @catch (NSException *e) { + // ignore + } + + // Category, Action, UserText, UserData (as JSON data). + gocallback([categoryID UTF8String], [actionID UTF8String], [userText UTF8String], [[[NSString alloc] initWithBytes:[jsonData bytes] length:[jsonData length] encoding:NSUTF8StringEncoding] UTF8String]); + + completionHandler(); +} + +- (void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler { + notify_log(DEBUG, "NotifyUNDelegate: willPresentNotification"); +} + +- (void)userNotificationCenter:(UNUserNotificationCenter *)center openSettingsForNotification:(UNNotification *)notification { + notify_log(DEBUG, "NotifyUNDelegate: openSettingsForNotification"); +} +@end + +UNUserNotificationCenter *nc; +NotifyUNDelegate *del; +BOOL enabled; +BOOL hasBundle; + +// noti_setup registers the app delegate. +void +noti_setup() { + notify_log(DEBUG, "Getting application bundle"); + enabled = NO; + hasBundle = NO; + NSBundle *main = [NSBundle mainBundle]; + if (main.bundleIdentifier == nil) { + notify_log(ERROR, "No app bundle."); + return; + } + hasBundle = YES; + notify_log(DEBUG, "Bundle ID: %@", main.bundleIdentifier); + nc = [UNUserNotificationCenter currentNotificationCenter]; + del = [NotifyUNDelegate new]; + nc.delegate = del; +} + +// noti_category_register adds a notification category. +void +noti_category_register(Category category) { + NSString *category_id = [[NSString alloc] initWithUTF8String:category.id]; + NSString *preview = [[NSString alloc] initWithUTF8String:category.preview]; + NSString *summary = [[NSString alloc] initWithUTF8String:category.summary]; + + if (!hasBundle) { + return; + } + + NSMutableArray *actions_array = [NSMutableArray new]; + + /* + Iterate both types of actions and add them to the notification center. + */ + + for (uintptr_t ii = 0; ii < category.actions_len; ii++) { + Action a = category.actions[ii]; + + NSString *a_id = [[NSString alloc] initWithUTF8String:a.id]; + NSString *a_icon = [[NSString alloc] initWithUTF8String:a.icon]; + NSString *a_title = [[NSString alloc] initWithUTF8String:a.title]; + + NSString *a_icon_path = [[NSBundle mainBundle] pathForResource:a_icon.stringByDeletingPathExtension ofType:a_icon.pathExtension]; + + [actions_array addObject:[UNNotificationAction actionWithIdentifier:a_id title:a_title options:0 icon:[UNNotificationActionIcon iconWithTemplateImageName:a_icon_path]]]; + } + + for (uintptr_t ii = 0; ii < category.text_input_actions_len; ii++) { + TextInputAction tia = category.text_input_actions[ii]; + + NSString *tia_id = [[NSString alloc] initWithUTF8String:tia.id]; + NSString *tia_icon = [[NSString alloc] initWithUTF8String:tia.icon]; + NSString *tia_title = [[NSString alloc] initWithUTF8String:tia.title]; + NSString *tia_placeholder = [[NSString alloc] initWithUTF8String:tia.placeholder]; + NSString *tia_button_title = [[NSString alloc] initWithUTF8String:tia.button_title]; + + NSString *tia_icon_path = [[NSBundle mainBundle] pathForResource:tia_icon.stringByDeletingPathExtension ofType:tia_icon.pathExtension]; + + [actions_array addObject:[UNTextInputNotificationAction actionWithIdentifier:tia_id title:tia_title options:UNNotificationActionOptionForeground icon:[UNNotificationActionIcon iconWithTemplateImageName:tia_icon_path] textInputButtonTitle:tia_button_title textInputPlaceholder:tia_placeholder]]; + } + + UNNotificationCategory *un_category = [UNNotificationCategory categoryWithIdentifier:category_id actions:actions_array intentIdentifiers:[NSArray new] hiddenPreviewsBodyPlaceholder:preview categorySummaryFormat:summary options:0]; + + + // We must take care to merge the categories into a single set + // otherwise we will overwrite the existing categories with this + // one. + + UNUserNotificationCenter *nc = [UNUserNotificationCenter currentNotificationCenter]; + + [nc getNotificationCategoriesWithCompletionHandler: ^(NSSet<UNNotificationCategory *> * categories) { + NSMutableSet *set = [NSMutableSet setWithSet:categories]; + [set addObject:un_category]; + [nc setNotificationCategories:set]; + }]; + + return; +} + +// noti_notify fires a notification. +void +noti_notify(Notification n) { + if (!hasBundle) { + notify_log(ERROR, "no bundle"); + return; + } + + notify_log(DEBUG, "creating notification"); + + dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); + + UNMutableNotificationContent *note = [[UNMutableNotificationContent alloc] init]; + + note.title = [[NSString alloc] initWithUTF8String: n.title]; + note.subtitle = [[NSString alloc] initWithUTF8String: n.sub_title]; + note.body = [[NSString alloc] initWithUTF8String: n.body]; + note.categoryIdentifier = [[NSString alloc] initWithUTF8String: n.category_id]; + + /* + + Process attachments. + + */ + + NSMutableArray *attachments_array = [NSMutableArray new]; + + for (uintptr_t ii = 0; ii < n.attachments_len; ii++) { + + NSError *error; + NSString *attachment_id = @""; // id will be generated for us. + NSString *attachment_name = [[NSString alloc] initWithUTF8String:n.attachments[ii]]; + NSString *attachment_path = [[NSBundle mainBundle] pathForResource:attachment_name.stringByDeletingPathExtension ofType:attachment_name.pathExtension]; + NSURL *url = [NSURL fileURLWithPath:attachment_path?attachment_path:attachment_name]; + + // TODO: add ability to express clipping rectangle options dictionary. + UNNotificationAttachment *attachment = [UNNotificationAttachment attachmentWithIdentifier:attachment_id URL:url options:nil error:&error]; + + notify_log(DEBUG, "attachment: %@", attachment); + + if (attachment) { + [attachments_array addObject:attachment]; + } else { + notify_log(ERROR, "attachment not found for %@", attachment_name); + } + } + + note.attachments = attachments_array; + + /* + + Process user data. + + */ + + NSError *json_error; + NSData *user_data_data = [[[NSString alloc] initWithUTF8String:n.user_data] dataUsingEncoding:NSUTF8StringEncoding]; + NSDictionary *user_data_dict = [NSJSONSerialization JSONObjectWithData:user_data_data options:NSJSONReadingFragmentsAllowed error:&json_error]; + + if (json_error) { + notify_log(ERROR, "unmarshalling user data into dictionary: %@", json_error); + } + + note.userInfo = user_data_dict; + + notify_log(DEBUG, "user info: %@", user_data_dict); + + /* + + Build and register ad-hoc category. + This logic kicks in the there is not category id set on the notification + and there is one or more actions. + + */ + + if ([note.categoryIdentifier length] == 0 && (n.actions_len > 0 || n.text_input_actions_len > 0)) { + notify_log(DEBUG, "creating adhoc category for notification"); + + NSString *adhoc_category_id = [[NSUUID UUID] UUIDString]; + + noti_category_register((Category){ + .id = [adhoc_category_id UTF8String], + .summary = "adhoc category", + .preview = "", + .actions = n.actions, + .actions_len = n.actions_len, + .text_input_actions = n.text_input_actions, + .text_input_actions_len = n.text_input_actions_len, + }); + + note.categoryIdentifier = adhoc_category_id; + + } + + /* + + Submit request. + + */ + + notify_log(DEBUG, "creating request"); + + UNNotificationRequest *req = [UNNotificationRequest requestWithIdentifier:[NSBundle mainBundle].bundleIdentifier content: note trigger:nil]; + + notify_log(DEBUG, "requesting authorization"); + + [nc requestAuthorizationWithOptions: UNAuthorizationOptionBadge | UNAuthorizationOptionSound | UNAuthorizationOptionAlert completionHandler: ^(BOOL granted, NSError *error){ + notify_log(DEBUG, "granted = %s", granted?"true":"false"); + + if (error) { + notify_log(ERROR, "authorizing: %@", error); + return; + } + + enabled = granted; + + if (enabled == YES) { + notify_log(DEBUG, "adding notification request"); + + [nc addNotificationRequest:req withCompletionHandler: ^(NSError *error) { + if (error) { + notify_log(ERROR, "adding notification: error: %@", error); + } + dispatch_semaphore_signal(semaphore); + }]; + + } else { + notify_log(ERROR, "permission denied"); + dispatch_semaphore_signal(semaphore); + } + }]; + + dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER); + + return; +} + +// noti_cancel cancels the specified notification. +void +noti_cancel() { + if (!hasBundle) { + notify_log(ERROR, "no bundle"); + return; + } + + NSString *nid = [NSBundle mainBundle].bundleIdentifier; + + @try { + [nc removePendingNotificationRequestsWithIdentifiers: @[(NSString*)nid]]; + [nc removeDeliveredNotificationsWithIdentifiers: @[(NSString*)nid]]; + } + + @catch(NSException *ne) { + notify_log(ERROR, "caught exception when cancelling notification %@: %@", nid, ne); + } +} +