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:
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, ¬ifyButton, "notify").Layout(gtx)
+ }),
+ layout.Rigid(layout.Spacer{Width: 8}.Layout),
+ layout.Rigid(func(gtx C) D {
+ return material.Button(th, ¬ifyAdhocButton, "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);
+ }
+}
+