/*
* tkMacOSXSysTray.c --
*
* tkMacOSXSysTray.c implements a "systray" Tcl command which allows
* one to change the system tray/taskbar icon of a Tk toplevel
* window and a "sysnotify" command to post system notifications.
* In macOS the icon appears on the right hand side of the menu bar.
*
* Copyright © 2020 Kevin Walzer/WordTech Communications LLC.
* Copyright © 2020 Jan Nijtmans.
* Copyright © 2020 Marc Culler.
*
* See the file "license.terms" for information on usage and redistribution
* of this file, and for a DISCLAIMER OF ALL WARRANTIES.
*/
#include <tkInt.h>
#include <tkMacOSXInt.h>
#include "tkMacOSXPrivate.h"
/*
* Prior to macOS 10.14 user notifications were handled by the NSApplication's
* NSUserNotificationCenter via a NSUserNotificationCenterDelegate object.
* These classes were defined in the CoreFoundation framework. In macOS 10.14
* a separate UserNotifications framework was introduced which adds some
* additional features, including custom controls on the notification window
* but primarily a requirement that an application must be authorized before
* being allowed to post a notification. This framework uses a different
* class, the UNUserNotificationCenter, and its delegate follows a different
* protocol, named UNUserNotificationCenterDelegate.
*
* In macOS 11.0 the NSUserNotificationCenter and its delegate protocol were
* deprecated. To make matters more complicated, it turns out that there is a
* secret undocumented additional requirement that an app which is not signed
* can never be authorized to send notifications via the UNNotificationCenter.
* (As of 11.0, it appears that it is sufficient to sign the app with a
* self-signed certificate, however.)
*
* The workaround implemented here is to define two classes, TkNSNotifier and
* TkUNNotifier, each of which provides one of these protocols on macOS 10.14
* and newer. If the TkUSNotifier is able to obtain authorization it is used.
* Otherwise, TkNSNotifier is used. Building TkNSNotifier on 11.0 or later
* produces deprecation warnings which are suppressed by enclosing the
* interface and implementation in #pragma blocks. The first time that the tk
* systray command in initialized in an interpreter an attempt is made to
* obtain authorization for sending notifications with the UNNotificationCenter
* on systems and the result is saved in a static variable.
*/
//#define DEBUG
#ifdef DEBUG
/*
* This macro uses the do ... while(0) trick to swallow semicolons. It logs to
* a temp file because apps launched from an icon have no stdout or stderr and
* because NSLog has a tendency to not produce any console messages at certain
* stages of launching an app.
*/
#define DEBUG_LOG(format, ...) \
do { \
FILE* logfile = fopen("/tmp/tklog", "a"); \
fprintf(logfile, format, ##__VA_ARGS__); \
fflush(logfile); \
fclose(logfile); } while (0)
#else
#define DEBUG_LOG(format, ...)
#endif
#define BUILD_TARGET_HAS_NOTIFICATION (MAC_OS_X_VERSION_MAX_ALLOWED >= 101000)
#define BUILD_TARGET_HAS_UN_FRAMEWORK (MAC_OS_X_VERSION_MAX_ALLOWED >= 101400)
#if MAC_OS_X_VERSION_MAX_ALLOWED > 101500
#define ALERT_OPTION UNNotificationPresentationOptionList | \
UNNotificationPresentationOptionBanner
#else
#define ALERT_OPTION UNNotificationPresentationOptionAlert
#endif
#if BUILD_TARGET_HAS_UN_FRAMEWORK
#import <UserNotifications/UserNotifications.h>
static NSString *TkNotificationCategory;
#endif
#if BUILD_TARGET_HAS_NOTIFICATION
/*
* Class declaration for TkStatusItem.
*/
@interface TkStatusItem: NSObject {
NSStatusItem * statusItem;
NSStatusBar * statusBar;
NSImage * icon;
NSString * tooltip;
Tcl_Interp * interp;
Tcl_Obj * b1_callback;
Tcl_Obj * b3_callback;
}
- (id) init : (Tcl_Interp *) interp;
- (void) setImagewithImage : (NSImage *) image;
- (void) setTextwithString : (NSString *) string;
- (void) setB1Callback : (Tcl_Obj *) callback;
- (void) setB3Callback : (Tcl_Obj *) callback;
- (void) clickOnStatusItem;
- (void) dealloc;
@end
/*
* Class declaration for TkNSNotifier. A TkNSNotifier object has no attributes
* but implements the NSUserNotificationCenterDelegate protocol. It also has
* one additional method which posts a user notification. There is one
* TkNSNotifier for the application, shared by all interpreters.
*/
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
@interface TkNSNotifier: NSObject {
}
/*
* Post a notification.
*/
- (void) postNotificationWithTitle : (NSString *) title message: (NSString *) detail;
/*
* The following methods comprise the NSUserNotificationCenterDelegate protocol.
*/
- (void) userNotificationCenter:(NSUserNotificationCenter *)center
didDeliverNotification:(NSUserNotification *)notification;
- (void) userNotificationCenter:(NSUserNotificationCenter *)center
didActivateNotification:(NSUserNotification *)notification;
- (BOOL) userNotificationCenter:(NSUserNotificationCenter *)center
shouldPresentNotification:(NSUserNotification *)notification;
@end
#pragma clang diagnostic pop
/*
* The singleton instance of TkNSNotifier shared by all interpreters in this
* application.
*/
static TkNSNotifier *NSnotifier = nil;
#if BUILD_TARGET_HAS_UN_FRAMEWORK
/*
* Class declaration for TkUNNotifier. A TkUNNotifier object has no attributes
* but implements the UNUserNotificationCenterDelegate protocol It also has two
* additional methods. One requests authorization to post notification via the
* UserNotification framework and the other posts a user notification. There is
* at most one TkUNNotifier for the application, shared by all interpreters.
*/
@interface TkUNNotifier: NSObject {
}
/*
* Request authorization to post a notification.
*/
- (void) requestAuthorization;
/*
* Post a notification.
*/
- (void) postNotificationWithTitle : (NSString *) title message: (NSString *) detail;
/*
* The following methods comprise the UNNotificationCenterDelegate protocol:
*/
- (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
/*
* The singleton instance of TkUNNotifier shared by all interpeters is stored
* in this static variable.
*/
static TkUNNotifier *UNnotifier = nil;
#endif
/*
* Class declaration for TkStatusItem. A TkStatusItem represents an icon posted
* on the status bar located on the right side of the MenuBar. Each interpreter
* may have at most one TkStatusItem. A pointer to the TkStatusItem belonging
* to an interpreter is stored as the clientData of the MacSystrayObjCmd instance
* in that interpreter. It will be NULL until the tk systray command is executed
* by the interpreter.
*/
@implementation TkStatusItem : NSObject
- (id) init : (Tcl_Interp *) interpreter {
[super init];
statusBar = [NSStatusBar systemStatusBar];
statusItem = [[statusBar statusItemWithLength:NSVariableStatusItemLength] retain];
statusItem.button.target = self;
statusItem.button.action = @selector(clickOnStatusItem);
[statusItem.button sendActionOn : NSEventMaskLeftMouseUp | NSEventMaskRightMouseUp];
statusItem.visible = YES;
interp = interpreter;
b1_callback = NULL;
b3_callback = NULL;
return self;
}
- (void) setImagewithImage : (NSImage *) image
{
icon = nil;
icon = image;
statusItem.button.image = icon;
}
- (void) setTextwithString : (NSString *) string
{
tooltip = nil;
tooltip = string;
statusItem.button.toolTip = tooltip;
}
- (void) setB1Callback : (Tcl_Obj *) obj
{
if (obj != NULL) {
Tcl_IncrRefCount(obj);
}
if (b1_callback != NULL) {
Tcl_DecrRefCount(b1_callback);
}
b1_callback = obj;
}
- (void) setB3Callback : (Tcl_Obj *) obj
{
if (obj != NULL) {
Tcl_IncrRefCount(obj);
}
if (b3_callback != NULL) {
Tcl_DecrRefCount(b3_callback);
}
b3_callback = obj;
}
- (void) clickOnStatusItem
{
NSEvent *event = [NSApp currentEvent];
if (([event type] == NSEventTypeLeftMouseUp) && (b1_callback != NULL)) {
int result = Tcl_EvalObjEx(interp, b1_callback, TCL_EVAL_GLOBAL);
if (result != TCL_OK) {
Tcl_BackgroundException(interp, result);
}
} else {
if (([event type] == NSEventTypeRightMouseUp) && (b3_callback != NULL)) {
int result = Tcl_EvalObjEx(interp, b3_callback, TCL_EVAL_GLOBAL);
if (result != TCL_OK) {
Tcl_BackgroundException(interp, result);
}
}
}
}
- (void) dealloc
{
[statusBar removeStatusItem: statusItem];
if (b1_callback != NULL) {
Tcl_DecrRefCount(b1_callback);
}
if (b3_callback != NULL) {
Tcl_DecrRefCount(b3_callback);
}
[super dealloc];
}
@end
/*
* Type used for the ClientData of a MacSystrayObjCmd instance.
*/
typedef TkStatusItem** StatusItemInfo;
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
@implementation TkNSNotifier : NSObject
- (void) postNotificationWithTitle : (NSString * ) title
message: (NSString * ) detail
{
NSUserNotification *notification;
NSUserNotificationCenter *center;
center = [NSUserNotificationCenter defaultUserNotificationCenter];
notification = [[NSUserNotification alloc] init];
notification.title = title;
notification.informativeText = detail;
notification.soundName = NSUserNotificationDefaultSoundName;
DEBUG_LOG("Sending NSNotification.\n");
[center deliverNotification:notification];
}
/*
* Implementation of the NSUserNotificationDelegate protocol.
*/
- (BOOL) userNotificationCenter: (NSUserNotificationCenter *) center
shouldPresentNotification: (NSUserNotification *)notification
{
(void) center;
(void) notification;
return YES;
}
- (void) userNotificationCenter:(NSUserNotificationCenter *)center
didDeliverNotification:(NSUserNotification *)notification
{
(void) center;
(void) notification;
}
- (void) userNotificationCenter:(NSUserNotificationCenter *)center
didActivateNotification:(NSUserNotification *)notification
{
(void) center;
(void) notification;
}
@end
#pragma clang diagnostic pop
/*
* Static variable which records whether the app is authorized to send
* notifications via the UNUserNotificationCenter.
*/
#if BUILD_TARGET_HAS_UN_FRAMEWORK
@implementation TkUNNotifier : NSObject
- (void) requestAuthorization
{
UNUserNotificationCenter *center;
UNAuthorizationOptions options = UNAuthorizationOptionAlert |
UNAuthorizationOptionSound |
UNAuthorizationOptionBadge |
UNAuthorizationOptionProvidesAppNotificationSettings;
if (![NSApp isSigned]) {
/*
* No point in even asking.
*/
DEBUG_LOG("Unsigned app: UNUserNotifications are not available.\n");
return;
}
center = [UNUserNotificationCenter currentNotificationCenter];
[center requestAuthorizationWithOptions: options
completionHandler: ^(BOOL granted, NSError* error)
{
if (error || granted == NO) {
DEBUG_LOG("Authorization for UNUserNotifications denied\n");
}
}];
}
- (void) postNotificationWithTitle: (NSString * ) title
message: (NSString * ) detail
{
UNUserNotificationCenter *center;
UNMutableNotificationContent* content;
UNNotificationRequest *request;
center = [UNUserNotificationCenter currentNotificationCenter];
center.delegate = (id) self;
content = [[UNMutableNotificationContent alloc] init];
content.title = title;
content.body = detail;
content.sound = [UNNotificationSound defaultSound];
content.categoryIdentifier = TkNotificationCategory;
request = [UNNotificationRequest
requestWithIdentifier:[[NSUUID UUID] UUIDString]
content:content
trigger:nil
];
[center addNotificationRequest: request
withCompletionHandler: ^(NSError* error) {
if (error) {
DEBUG_LOG("addNotificationRequest: error = %s\n", \
[NSString stringWithFormat:@"%@", \
error.userInfo].UTF8String);
}
}];
}
/*
* Implementation of the UNUserNotificationDelegate protocol.
*/
- (void) userNotificationCenter:(UNUserNotificationCenter *)center
didReceiveNotificationResponse:(UNNotificationResponse *)response
withCompletionHandler:(void (^)(void))completionHandler
{
/*
* Called when the user dismisses a notification.
*/
DEBUG_LOG("didReceiveNotification\n");
completionHandler();
}
- (void) userNotificationCenter:(UNUserNotificationCenter *)center
willPresentNotification:(UNNotification *)notification
withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler
{
/*
* This is called before presenting a notification, even when the user has
* turned off notifications.
*/
DEBUG_LOG("willPresentNotification\n");
#if MAC_OS_X_VERSION_MAX_ALLOWED >= 101400
if (@available(macOS 11.0, *)) {
completionHandler(ALERT_OPTION);
}
#endif
}
- (void) userNotificationCenter:(UNUserNotificationCenter *)center
openSettingsForNotification:(UNNotification *)notification
{
DEBUG_LOG("openSettingsForNotification\n");
// Does something need to be done here?
}
@end
#endif
/*
*----------------------------------------------------------------------
*
* MacSystrayDestroy --
*
* Removes an intepreters icon from the status bar.
*
* Results:
* None.
*
* Side effects:
* The icon is removed and memory is freed.
*
*----------------------------------------------------------------------
*/
static void
MacSystrayDestroy(
ClientData clientData,
TCL_UNUSED(Tcl_Interp *))
{
StatusItemInfo info = (StatusItemInfo)clientData;
if (info) {
[*info release];
ckfree(info);
}
}
/*
*----------------------------------------------------------------------
*
* MacSystrayObjCmd --
*
* Main command for creating, displaying, and removing icons from the
* status bar.
*
* Results:
*
* A standard Tcl result.
*
* Side effects:
*
* Management of icon display in the status bar.
*
*----------------------------------------------------------------------
*/
static int
MacSystrayObjCmd(
void *clientData,
Tcl_Interp * interp,
int objc,
Tcl_Obj *const *objv)
{
Tk_Image tk_image;
int result, idx;
static const char *options[] =
{"create", "modify", "destroy", NULL};
typedef enum {TRAY_CREATE, TRAY_MODIFY, TRAY_DESTROY} optionsEnum;
static const char *modifyOptions[] =
{"image", "text", "b1_callback", "b3_callback", NULL};
typedef enum {TRAY_IMAGE, TRAY_TEXT, TRAY_B1_CALLBACK, TRAY_B3_CALLBACK
} modifyOptionsEnum;
if ([NSApp macOSVersion] < 101000) {
Tcl_AppendResult(interp,
"StatusItem icons not supported on macOS versions lower than 10.10",
NULL);
return TCL_OK;
}
StatusItemInfo info = (StatusItemInfo)clientData;
TkStatusItem *statusItem = *info;
if (objc < 2) {
Tcl_WrongNumArgs(interp, 1, objv, "create | modify | destroy");
return TCL_ERROR;
}
result = Tcl_GetIndexFromObjStruct(interp, objv[1], options,
sizeof(char *), "command", 0, &idx);
if (result != TCL_OK) {
return TCL_ERROR;
}
switch((optionsEnum)idx) {
case TRAY_CREATE: {
if (objc < 3 || objc > 6) {
Tcl_WrongNumArgs(interp, 1, objv, "create -image -text -button1 -button3");
return TCL_ERROR;
}
if (statusItem == NULL) {
statusItem = [[TkStatusItem alloc] init: interp];
*info = statusItem;
} else {
Tcl_AppendResult(interp, "Only one system tray icon supported per interpreter", NULL);
return TCL_ERROR;
}
/*
* Create the icon.
*/
int width, height;
Tk_Window tkwin = Tk_MainWindow(interp);
TkWindow *winPtr = (TkWindow *)tkwin;
Display *d = winPtr->display;
NSImage *icon;
tk_image = Tk_GetImage(interp, tkwin, Tcl_GetString(objv[2]), NULL, NULL);
if (tk_image == NULL) {
return TCL_ERROR;
}
Tk_SizeOfImage(tk_image, &width, &height);
if (width != 0 && height != 0) {
icon = TkMacOSXGetNSImageFromTkImage(d, tk_image,
width, height);
[statusItem setImagewithImage: icon];
Tk_FreeImage(tk_image);
}
/*
* Set the text for the tooltip.
*/
NSString *tooltip = [NSString stringWithUTF8String: Tcl_GetString(objv[3])];
if (tooltip == nil) {
Tcl_AppendResult(interp, " unable to set tooltip for systray icon", NULL);
return TCL_ERROR;
}
[statusItem setTextwithString: tooltip];
/*
* Set the proc for the callback.
*/
[statusItem setB1Callback : (objc > 4) ? objv[4] : NULL];
[statusItem setB3Callback : (objc > 5) ? objv[5] : NULL];
break;
}
case TRAY_MODIFY: {
if (objc != 4) {
Tcl_WrongNumArgs(interp, 1, objv, "modify object item");
return TCL_ERROR;
}
/*
* Modify the icon.
*/
result = Tcl_GetIndexFromObjStruct(interp, objv[2], modifyOptions,
sizeof(char *), "option", 0, &idx);
if (result != TCL_OK) {
return TCL_ERROR;
}
switch ((modifyOptionsEnum)idx) {
case TRAY_IMAGE: {
Tk_Window tkwin = Tk_MainWindow(interp);
TkWindow *winPtr = (TkWindow *)tkwin;
Display *d = winPtr -> display;
NSImage *icon;
int width, height;
tk_image = Tk_GetImage(interp, tkwin, Tcl_GetString(objv[3]), NULL, NULL);
if (tk_image == NULL) {
Tcl_AppendResult(interp, " unable to obtain image for systray icon",
NULL);
return TCL_ERROR;
}
Tk_SizeOfImage(tk_image, &width, &height);
if (width != 0 && height != 0) {
icon = TkMacOSXGetNSImageFromTkImage(d, tk_image,
width, height);
[statusItem setImagewithImage: icon];
}
Tk_FreeImage(tk_image);
break;
}
/*
* Modify the text for the tooltip.
*/
case TRAY_TEXT: {
NSString *tooltip = [NSString stringWithUTF8String:Tcl_GetString(objv[3])];
if (tooltip == nil) {
Tcl_AppendResult(interp, "unable to set tooltip for systray icon",
NULL);
return TCL_ERROR;
}
[statusItem setTextwithString: tooltip];
break;
}
/*
* Modify the proc for the callback.
*/
case TRAY_B1_CALLBACK: {
[statusItem setB1Callback : objv[3]];
break;
}
case TRAY_B3_CALLBACK: {
[statusItem setB3Callback : objv[3]];
break;
}
}
break;
}
case TRAY_DESTROY: {
/*
* Set all properties to nil, and release statusItem.
*/
[statusItem setImagewithImage: nil];
[statusItem setTextwithString: nil];
[statusItem setB1Callback : NULL];
[statusItem setB3Callback : NULL];
[statusItem release];
*info = NULL;
statusItem = NULL;
break;
}
}
return TCL_OK;
}
/*
*----------------------------------------------------------------------
*
* SysNotifyObjCmd --
*
* Create system notification.
*
* Results:
*
* A standard Tcl result.
*
* Side effects:
*
* System notifications are posted.
*
*-------------------------------z---------------------------------------
*/
static int SysNotifyObjCmd(
TCL_UNUSED(void *),
Tcl_Interp * interp,
int objc,
Tcl_Obj *const *objv)
{
if (objc < 3) {
Tcl_WrongNumArgs(interp, 1, objv, "title message");
return TCL_ERROR;
}
if ([NSApp macOSVersion] < 101000) {
Tcl_AppendResult(interp,
"Notifications not supported on macOS versions lower than 10.10",
NULL);
return TCL_OK;
}
NSString *title = [NSString stringWithUTF8String: Tcl_GetString(objv[1])];
NSString *message = [NSString stringWithUTF8String: Tcl_GetString(objv[2])];
/*
* Update the authorization status in case the user enabled or disabled
* notifications after the app started up.
*/
#if BUILD_TARGET_HAS_UN_FRAMEWORK
if (UNnotifier && [NSApp isSigned]) {
UNUserNotificationCenter *center;
center = [UNUserNotificationCenter currentNotificationCenter];
[center getNotificationSettingsWithCompletionHandler:
^(UNNotificationSettings *settings)
{
#if !defined(DEBUG)
(void) settings;
#endif
DEBUG_LOG("Reported authorization status is %ld\n",
settings.authorizationStatus);
}];
}
#endif
if ([NSApp macOSVersion] < 101400 || ![NSApp isSigned]) {
DEBUG_LOG("Using the NSUserNotificationCenter\n");
[NSnotifier postNotificationWithTitle : title message: message];
} else {
#if BUILD_TARGET_HAS_UN_FRAMEWORK
DEBUG_LOG("Using the UNUserNotificationCenter\n");
[UNnotifier postNotificationWithTitle : title message: message];
#endif
}
return TCL_OK;
}
#endif // if BUILD_TARGET_HAS_NOTIFICATION
/*
*----------------------------------------------------------------------
*
* MacSystrayInit --
*
* Initialize this package and create script-level commands.
* This is called from TkpInit for each interpreter.
*
* Results:
*
* A standard Tcl result.
*
* Side effects:
*
* The tk systray and tk sysnotify commands are installed in an
* interpreter
*
*----------------------------------------------------------------------
*/
#if BUILD_TARGET_HAS_NOTIFICATION
int
MacSystrayInit(Tcl_Interp *interp)
{
/*
* Initialize the TkStatusItem for this interpreter and, if necessary,
* the shared TkNSNotifier and TkUNNotifier.
*/
StatusItemInfo info = (StatusItemInfo) ckalloc(sizeof(StatusItemInfo));
*info = 0;
if (NSnotifier == nil) {
NSnotifier = [[TkNSNotifier alloc] init];
}
#if BUILD_TARGET_HAS_UN_FRAMEWORK
if (@available(macOS 10.14, *)) {
UNUserNotificationCenter *center;
UNNotificationCategory *category;
NSSet *categories;
if (UNnotifier == nil) {
UNnotifier = [[TkUNNotifier alloc] init];
/*
* Request authorization to use the UserNotification framework. If
* the app code is signed and there are no notification preferences
* settings for this app, a dialog will be opened to prompt the
* user to choose settings. Note that the request is asynchronous,
* so even if the preferences setting exists the result is not
* available immediately.
*/
[UNnotifier requestAuthorization];
}
TkNotificationCategory = @"Basic Tk Notification";
center = [UNUserNotificationCenter currentNotificationCenter];
center = [UNUserNotificationCenter currentNotificationCenter];
category = [UNNotificationCategory
categoryWithIdentifier:TkNotificationCategory
actions:@[]
intentIdentifiers:@[]
options: UNNotificationCategoryOptionNone];
categories = [NSSet setWithObjects:category, nil];
[center setNotificationCategories: categories];
}
#endif
Tcl_CreateObjCommand(interp, "::tk::systray::_systray", MacSystrayObjCmd, info,
(Tcl_CmdDeleteProc *)MacSystrayDestroy);
Tcl_CreateObjCommand(interp, "::tk::sysnotify::_sysnotify", SysNotifyObjCmd, NULL, NULL);
return TCL_OK;
}
#else
int
MacSystrayInit(TCL_UNUSED(Tcl_Interp *))
{
return TCL_OK;
}
#endif // BUILD_TARGET_HAS_NOTIFICATION
/*
* Local Variables:
* mode: objc
* c-basic-offset: 4
* fill-column: 79
* coding: utf-8
* End:
*/