go-notify-darwin

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

noti.m (10977B)


      1 //go:build darwin && cgo
      2 
      3 #import <stdlib.h>
      4 #import "_cgo_export.h"
      5 
      6 #if  ! __has_feature(objc_arc)
      7     #error This file must be compiled with ARC. Use -fobjc-arc flag (or convert project to ARC).
      8 #endif
      9 
     10 @import Foundation;
     11 @import UserNotifications;
     12 
     13 // Log levels mirror Go's slog package.
     14 const int DEBUG = -4;
     15 const int INFO  = 0;
     16 const int WARN  = 4;
     17 const int ERROR = 8;
     18 
     19 // notify_log macro provides a var-args wrapper that can do formatting to the Go side log function.
     20 #define notify_log(level, fmt, ...) golog(level, [[NSString stringWithFormat:@fmt, ##__VA_ARGS__] UTF8String])
     21 
     22 @interface NotifyUNDelegate : NSObject <UNUserNotificationCenterDelegate>
     23 { }
     24 - (void)userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void (^)(void))completionHandler;
     25 - (void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler;
     26 - (void)userNotificationCenter:(UNUserNotificationCenter *)center openSettingsForNotification:(UNNotification *)notification;
     27 @end
     28 
     29 @implementation NotifyUNDelegate
     30 - (void)userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void (^)(void))completionHandler {
     31 	notify_log(DEBUG, "NotifyUNDelegate: didReceiveNotificationResponse %@", response);
     32 
     33 	NSError  *error; 
     34 	NSData   *jsonData   = [NSJSONSerialization dataWithJSONObject: response.notification.request.content.userInfo options: NSJSONWritingPrettyPrinted error: &error];
     35 	NSString *actionID   = response.actionIdentifier;
     36 	NSString *categoryID = response.notification.request.content.categoryIdentifier;
     37 	NSString *userText   = @"";
     38 
     39 	// NOTE(jfm): macOS passes a different object for text actions (UNTextInputNotificationResponse).
     40 	// Since we declare the method to use UNNotificationResponse, we must use reflection to grab the 
     41 	// userText property when the concrete object is actually UNTextInputNotificationResponse.
     42 	// In the case of a non-text action the userText will be the empty string. 
     43 
     44 	@try {
     45 		userText = [response valueForKey:@"userText"];
     46 	}
     47 	@catch (NSException *e) {
     48 		// ignore
     49 	}
     50 
     51 	// Category, Action, UserText, UserData (as JSON data).
     52 	gocallback([categoryID UTF8String], [actionID UTF8String], [userText UTF8String], [[[NSString alloc] initWithBytes:[jsonData bytes] length:[jsonData length] encoding:NSUTF8StringEncoding] UTF8String]);
     53 
     54 	completionHandler();
     55 }
     56 
     57 - (void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler {
     58 	notify_log(DEBUG, "NotifyUNDelegate: willPresentNotification");
     59 }
     60 
     61 - (void)userNotificationCenter:(UNUserNotificationCenter *)center openSettingsForNotification:(UNNotification *)notification {
     62 	notify_log(DEBUG, "NotifyUNDelegate: openSettingsForNotification");
     63 }
     64 @end
     65 
     66 UNUserNotificationCenter *nc;
     67 NotifyUNDelegate *del;
     68 BOOL enabled;
     69 BOOL hasBundle;
     70 
     71 // noti_setup registers the app delegate.
     72 void
     73 noti_setup() {
     74 	notify_log(DEBUG, "Getting application bundle");
     75 	enabled = NO;
     76 	hasBundle = NO;
     77 	NSBundle *main = [NSBundle mainBundle];
     78 	if (main.bundleIdentifier == nil) {
     79 		notify_log(ERROR, "No app bundle.");
     80 		return;
     81 	}
     82 	hasBundle = YES;
     83 	notify_log(DEBUG, "Bundle ID: %@", main.bundleIdentifier);
     84 	nc = [UNUserNotificationCenter currentNotificationCenter];
     85 	del = [NotifyUNDelegate new];
     86 	nc.delegate = del;
     87 }
     88 
     89 // noti_category_register adds a notification category.
     90 void
     91 noti_category_register(Category category) {
     92 	NSString *category_id = [[NSString alloc] initWithUTF8String:category.id];
     93 	NSString *preview     = [[NSString alloc] initWithUTF8String:category.preview];
     94 	NSString *summary     = [[NSString alloc] initWithUTF8String:category.summary];
     95 
     96 	if (!hasBundle) {
     97 		return;
     98 	}
     99 
    100 	NSMutableArray *actions_array = [NSMutableArray new];
    101 
    102 	/*
    103 		Iterate both types of actions and add them to the notification center.
    104 	*/
    105 
    106 	for (uintptr_t ii = 0; ii < category.actions_len; ii++) {
    107 		Action a = category.actions[ii];
    108 
    109 		NSString *a_id    = [[NSString alloc] initWithUTF8String:a.id];
    110 		NSString *a_icon  = [[NSString alloc] initWithUTF8String:a.icon];
    111 		NSString *a_title = [[NSString alloc] initWithUTF8String:a.title];
    112 
    113 		NSString *a_icon_path = [[NSBundle mainBundle] pathForResource:a_icon.stringByDeletingPathExtension ofType:a_icon.pathExtension]; 
    114 
    115 		[actions_array addObject:[UNNotificationAction actionWithIdentifier:a_id title:a_title options:0 icon:[UNNotificationActionIcon iconWithTemplateImageName:a_icon_path]]];
    116 	}
    117 
    118 	for (uintptr_t ii = 0; ii < category.text_input_actions_len; ii++) {
    119 		TextInputAction tia = category.text_input_actions[ii];
    120 
    121 		NSString *tia_id           = [[NSString alloc] initWithUTF8String:tia.id];
    122 		NSString *tia_icon         = [[NSString alloc] initWithUTF8String:tia.icon];
    123 		NSString *tia_title        = [[NSString alloc] initWithUTF8String:tia.title];
    124 		NSString *tia_placeholder  = [[NSString alloc] initWithUTF8String:tia.placeholder];
    125 		NSString *tia_button_title = [[NSString alloc] initWithUTF8String:tia.button_title];
    126 
    127 		NSString *tia_icon_path = [[NSBundle mainBundle] pathForResource:tia_icon.stringByDeletingPathExtension ofType:tia_icon.pathExtension]; 
    128 
    129 		[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]];
    130 	}
    131 
    132 	UNNotificationCategory *un_category = [UNNotificationCategory categoryWithIdentifier:category_id actions:actions_array intentIdentifiers:[NSArray new] hiddenPreviewsBodyPlaceholder:preview categorySummaryFormat:summary options:0];
    133 
    134 
    135 	// We must take care to merge the categories into a single set
    136 	// otherwise we will overwrite the existing categories with this
    137 	// one.
    138 
    139 	UNUserNotificationCenter *nc = [UNUserNotificationCenter currentNotificationCenter];
    140 
    141 	[nc getNotificationCategoriesWithCompletionHandler: ^(NSSet<UNNotificationCategory *> * categories) {
    142 		NSMutableSet *set = [NSMutableSet setWithSet:categories];
    143 		[set addObject:un_category];
    144 		[nc setNotificationCategories:set];
    145 	}];
    146 
    147 	return;
    148 }
    149 
    150 // noti_notify fires a notification.
    151 void
    152 noti_notify(Notification n) {
    153 	if (!hasBundle) {
    154 		notify_log(ERROR, "no bundle");
    155 		return;
    156 	}
    157 
    158 	notify_log(DEBUG, "creating notification");
    159 
    160 	dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
    161 
    162 	UNMutableNotificationContent *note = [[UNMutableNotificationContent alloc] init];
    163 
    164 	note.title              = [[NSString alloc] initWithUTF8String: n.title];
    165 	note.subtitle           = [[NSString alloc] initWithUTF8String: n.sub_title];
    166 	note.body               = [[NSString alloc] initWithUTF8String: n.body];
    167 	note.categoryIdentifier = [[NSString alloc] initWithUTF8String: n.category_id];
    168 
    169 	/*
    170 
    171 		Process attachments.
    172 
    173 	*/
    174 
    175 	NSMutableArray *attachments_array = [NSMutableArray new];
    176 
    177 	for (uintptr_t ii = 0; ii < n.attachments_len; ii++) {
    178 
    179 		NSError *error;
    180 		NSString *attachment_id   = @""; // id will be generated for us.
    181 		NSString *attachment_name = [[NSString alloc] initWithUTF8String:n.attachments[ii]];
    182 		NSString *attachment_path = [[NSBundle mainBundle] pathForResource:attachment_name.stringByDeletingPathExtension ofType:attachment_name.pathExtension];
    183 		NSURL    *url             = [NSURL fileURLWithPath:attachment_path?attachment_path:attachment_name];
    184 
    185 		// TODO: add ability to express clipping rectangle options dictionary.
    186 		UNNotificationAttachment *attachment = [UNNotificationAttachment attachmentWithIdentifier:attachment_id URL:url options:nil error:&error];
    187 
    188 		notify_log(DEBUG, "attachment: %@", attachment);
    189 
    190 		if (attachment) {
    191 			[attachments_array addObject:attachment];
    192 		} else {
    193 			notify_log(ERROR, "attachment not found for %@", attachment_name);
    194 		}
    195 	}
    196 
    197 	note.attachments = attachments_array;
    198 	
    199 	/*
    200 
    201 		Process user data.
    202 
    203 	*/
    204 
    205 	NSError      *json_error;
    206 	NSData       *user_data_data = [[[NSString alloc] initWithUTF8String:n.user_data] dataUsingEncoding:NSUTF8StringEncoding];
    207 	NSDictionary *user_data_dict = [NSJSONSerialization JSONObjectWithData:user_data_data options:NSJSONReadingFragmentsAllowed error:&json_error];
    208 
    209 	if (json_error) {
    210 		notify_log(ERROR, "unmarshalling user data into dictionary: %@", json_error);
    211 	}
    212 
    213 	note.userInfo = user_data_dict;
    214 
    215 	notify_log(DEBUG, "user info: %@", user_data_dict);
    216 
    217 	/*
    218 
    219 		Build and register ad-hoc category.
    220 		This logic kicks in the there is not category id set on the notification 
    221 		and there is one or more actions.
    222 
    223 	*/
    224 
    225 	if ([note.categoryIdentifier length] == 0 && (n.actions_len > 0 || n.text_input_actions_len > 0)) {
    226 		notify_log(DEBUG, "creating adhoc category for notification");
    227 
    228 		NSString *adhoc_category_id = [[NSUUID UUID] UUIDString];
    229 
    230 		noti_category_register((Category){
    231 			.id = [adhoc_category_id UTF8String],
    232 			.summary = "adhoc category",
    233 			.preview = "",
    234 			.actions = n.actions,
    235 			.actions_len = n.actions_len,
    236 			.text_input_actions = n.text_input_actions,
    237 			.text_input_actions_len = n.text_input_actions_len,
    238 		});
    239 
    240 		note.categoryIdentifier = adhoc_category_id;
    241 		
    242 	}
    243 
    244 	/*
    245 	
    246 		Submit request.
    247 
    248 	*/
    249 
    250 	notify_log(DEBUG, "creating request");
    251 
    252 	UNNotificationRequest *req = [UNNotificationRequest requestWithIdentifier:[NSBundle mainBundle].bundleIdentifier content: note trigger:nil];
    253 
    254 	notify_log(DEBUG, "requesting authorization");
    255 
    256 	[nc requestAuthorizationWithOptions: UNAuthorizationOptionBadge | UNAuthorizationOptionSound | UNAuthorizationOptionAlert completionHandler: ^(BOOL granted, NSError *error){
    257 		notify_log(DEBUG, "granted = %s", granted?"true":"false");
    258 
    259 		if (error) {
    260 			notify_log(ERROR, "authorizing: %@", error);
    261 			return;
    262 		}
    263 
    264 		enabled = granted;
    265 
    266 		if (enabled == YES) {
    267   		notify_log(DEBUG, "adding notification request");
    268 
    269   		[nc addNotificationRequest:req withCompletionHandler: ^(NSError *error) {
    270 				if (error) {
    271 	  			notify_log(ERROR, "adding notification: error: %@", error);
    272 				}
    273   			dispatch_semaphore_signal(semaphore);
    274   		}];
    275 
    276 		} else {
    277 			notify_log(ERROR, "permission denied");
    278 			dispatch_semaphore_signal(semaphore);
    279 		}
    280 	}];
    281 
    282 	dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
    283 
    284 	return;
    285 }
    286 
    287 // noti_cancel cancels the specified notification.
    288 void
    289 noti_cancel() {
    290 	if (!hasBundle) {
    291 		notify_log(ERROR, "no bundle");
    292 		return;
    293 	}
    294 
    295 	NSString *nid = [NSBundle mainBundle].bundleIdentifier;
    296 
    297   @try {
    298 			[nc removePendingNotificationRequestsWithIdentifiers: @[(NSString*)nid]];
    299 			[nc removeDeliveredNotificationsWithIdentifiers: @[(NSString*)nid]];
    300   }
    301 
    302   @catch(NSException *ne) {
    303       notify_log(ERROR, "caught exception when cancelling notification %@: %@", nid, ne);
    304   }
    305 }
    306