Notify.NET/native/MacNotifyWrapper/MacNotifyWrapper.m
2026-06-14 15:00:42 -05:00

662 lines
24 KiB
Objective-C

/**
* MacNotifyWrapper.m
*
* Objective-C implementation of the flat C API declared in MacNotifyWrapper.h.
* Wraps UNUserNotificationCenter (macOS 10.14+).
*
* Compilation requirements:
* clang -fobjc-arc -fvisibility=hidden
* Frameworks: Foundation, UserNotifications
* Minimum deployment target: macOS 10.14 (for x86_64), macOS 11.0 (for arm64)
*
* Threading model:
* MNW_Initialize: blocks on a semaphore waiting for the authorisation dialog.
* MNW_ShowNotification / MNW_HideNotification: thread-safe; UNUserNotificationCenter
* internally serialises requests.
* Callbacks (onActivated, onButtonActivated, onDismissed, onFailed): invoked on a
* background GCD thread managed by UNUserNotificationCenter. Callers must not
* call back into MNW_* synchronously from these callbacks.
*/
#define MACNOTIFYWRAPPER_EXPORTS
#import <Foundation/Foundation.h>
#import <AppKit/AppKit.h>
#import <UserNotifications/UserNotifications.h>
#import <objc/runtime.h>
#include <stdatomic.h>
#include <stdlib.h>
#include "MacNotifyWrapper.h"
/* -------------------------------------------------------------------------
* Per-notification heap state
* ------------------------------------------------------------------------- */
typedef struct {
int64_t notifId;
MNW_Handler handler;
} NotifState;
/* -------------------------------------------------------------------------
* Delegate
* ------------------------------------------------------------------------- */
@interface MNWDelegate : NSObject <UNUserNotificationCenterDelegate>
@end
/* -------------------------------------------------------------------------
* Process-lifetime globals
* ------------------------------------------------------------------------- */
static MNWDelegate* g_delegate = nil;
static NSLock* g_lock = nil;
/* strId → NSValue wrapping NotifState* (heap-allocated) */
static NSMutableDictionary<NSString*, NSValue*>* g_entries = nil;
/* int64 NSNumber → strId NSString */
static NSMutableDictionary<NSNumber*, NSString*>* g_idMap = nil;
/* category identifiers we have registered */
static NSMutableSet<NSString*>* g_categoryIds = nil;
/* UNNotificationCategory objects corresponding to the above */
static NSMutableSet<UNNotificationCategory*>* g_categoryObjs = nil;
static _Atomic(int64_t) g_counter = 1;
static bool g_initialized = false;
/* -------------------------------------------------------------------------
* Private helpers
* ------------------------------------------------------------------------- */
static NSString* StringIdFor(int64_t notifId)
{
return [NSString stringWithFormat:@"mnw_%lld", (long long)notifId];
}
/* Stores a newly allocated NotifState in both lookup tables. */
static void StoreEntry(NSString* strId, int64_t notifId, const MNW_Handler* handler)
{
NotifState* s = (NotifState*)malloc(sizeof(NotifState));
s->notifId = notifId;
s->handler = *handler;
NSValue* boxed = [NSValue valueWithPointer:s];
[g_lock lock];
g_entries[strId] = boxed;
g_idMap[@(notifId)] = strId;
[g_lock unlock];
}
/*
* Atomically removes the entry and returns a copy of its state.
* Returns NO if no entry exists (already removed by a prior event).
*/
static BOOL TakeEntry(NSString* strId, NotifState* outState)
{
[g_lock lock];
NSValue* val = g_entries[strId];
if (!val) { [g_lock unlock]; return NO; }
NotifState* s = (NotifState*)[val pointerValue];
*outState = *s;
free(s);
[g_entries removeObjectForKey:strId];
[g_idMap removeObjectForKey:@(outState->notifId)];
[g_lock unlock];
return YES;
}
/*
* Builds a stable, reproducible category identifier from a set of button labels.
* The identifier must survive app restarts so that previously delivered notifications
* whose category was registered in an earlier session are still actionable.
*/
static NSString* BuildCategoryId(const char** labels, int count)
{
if (count == 0) return @"mnw_default";
NSMutableString* s = [NSMutableString stringWithString:@"mnw"];
for (int i = 0; i < count; i++)
[s appendFormat:@"_%s", labels[i]];
/* Hash long identifiers to keep the string short. */
if (s.length > 80)
return [NSString stringWithFormat:@"mnw_%lu", (unsigned long)s.hash];
return [s copy];
}
/*
* Ensures a UNNotificationCategory with the given identifier is registered.
* No-op if the category was already registered this session.
* Thread-safe — uses g_lock.
*/
static NSString* EnsureCategory(const char** labels, int count)
{
NSString* catId = BuildCategoryId(labels, count);
[g_lock lock];
BOOL known = [g_categoryIds containsObject:catId];
[g_lock unlock];
if (known) return catId;
/* Build the UNNotificationAction array. */
NSMutableArray<UNNotificationAction*>* actions = [NSMutableArray array];
for (int i = 0; i < count; i++) {
NSString* actionId = [NSString stringWithFormat:@"btn_%d", i];
NSString* title = [NSString stringWithUTF8String:labels[i]];
[actions addObject:[UNNotificationAction
actionWithIdentifier:actionId
title:title
options:UNNotificationActionOptionNone]];
}
/* CustomDismissAction makes the delegate receive dismiss events. */
UNNotificationCategory* category = [UNNotificationCategory
categoryWithIdentifier:catId
actions:actions
intentIdentifiers:@[]
options:UNNotificationCategoryOptionCustomDismissAction];
[g_lock lock];
if (![g_categoryIds containsObject:catId]) {
[g_categoryIds addObject:catId];
[g_categoryObjs addObject:category];
/* setNotificationCategories replaces the full set; pass our accumulated set. */
NSSet* snapshot = [g_categoryObjs copy];
[[UNUserNotificationCenter currentNotificationCenter]
setNotificationCategories:snapshot];
}
[g_lock unlock];
return catId;
}
/* -------------------------------------------------------------------------
* Delegate implementation
* ------------------------------------------------------------------------- */
@implementation MNWDelegate
/*
* Called when a notification arrives while the app is in the foreground.
* Show the banner and play the sound so that console/background processes
* still see the notification visually.
*/
- (void)userNotificationCenter:(UNUserNotificationCenter*)center
willPresentNotification:(UNNotification*)notification
withCompletionHandler:(void(^)(UNNotificationPresentationOptions))completionHandler
{
if (@available(macOS 12.0, *)) {
completionHandler(UNNotificationPresentationOptionBanner
| UNNotificationPresentationOptionSound
| UNNotificationPresentationOptionList);
} else {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
completionHandler(UNNotificationPresentationOptionAlert
| UNNotificationPresentationOptionSound);
#pragma clang diagnostic pop
}
}
/*
* Called when the user interacts with (or dismisses) a notification.
* This is the single delivery point for all notification responses;
* we route to the appropriate managed callback and then clean up.
*
* Note: on macOS, a body-tap or button-tap IS the terminal event.
* UNUserNotificationCenter does NOT subsequently fire a dismiss event
* after an action response. We therefore release after every interaction.
*/
- (void)userNotificationCenter:(UNUserNotificationCenter*)center
didReceiveNotificationResponse:(UNNotificationResponse*)response
withCompletionHandler:(void(^)(void))completionHandler
{
NSString* strId = response.notification.request.identifier;
NotifState state = {0};
if (!TakeEntry(strId, &state)) {
/* Already handled (e.g. MNW_HideNotification was called first). */
completionHandler();
return;
}
NSString* actionId = response.actionIdentifier;
if ([actionId isEqualToString:UNNotificationDefaultActionIdentifier]) {
/* User tapped the notification body. */
if (state.handler.onActivated)
state.handler.onActivated(state.notifId);
} else if ([actionId isEqualToString:UNNotificationDismissActionIdentifier]) {
/* User dismissed (swipe/close). Requires CustomDismissAction on category. */
if (state.handler.onDismissed)
state.handler.onDismissed(state.notifId, MNW_DISMISS_USER);
} else if ([actionId hasPrefix:@"btn_"]) {
/* User tapped an action button. */
int idx = (int)[[actionId substringFromIndex:4] integerValue];
if (state.handler.onButtonActivated)
state.handler.onButtonActivated(state.notifId, idx);
} else {
/* Unknown action — treat as activation. */
if (state.handler.onActivated)
state.handler.onActivated(state.notifId);
}
completionHandler();
}
@end
/* -------------------------------------------------------------------------
* API implementation
* ------------------------------------------------------------------------- */
bool MNW_IsSupported(void)
{
if (@available(macOS 10.14, *)) return true;
return false;
}
bool MNW_Initialize(const char* appName)
{
(void)appName; /* appName is informational; the bundle identifier governs delivery. */
if (!MNW_IsSupported()) return false;
/* One-time setup of global state. */
static dispatch_once_t once;
dispatch_once(&once, ^{
g_lock = [[NSLock alloc] init];
g_entries = [NSMutableDictionary dictionary];
g_idMap = [NSMutableDictionary dictionary];
g_categoryIds = [NSMutableSet set];
g_categoryObjs = [NSMutableSet set];
g_delegate = [[MNWDelegate alloc] init];
[[UNUserNotificationCenter currentNotificationCenter] setDelegate:g_delegate];
/* Pre-register the default (no-button) category. */
EnsureCategory(NULL, 0);
});
if (g_initialized) {
/* On re-initialisation just re-check authorisation status. */
__block bool ok = false;
dispatch_semaphore_t sem = dispatch_semaphore_create(0);
[[UNUserNotificationCenter currentNotificationCenter]
getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings* s) {
ok = (s.authorizationStatus == UNAuthorizationStatusAuthorized
|| s.authorizationStatus == UNAuthorizationStatusProvisional);
dispatch_semaphore_signal(sem);
}];
dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC));
return ok;
}
/* Request authorisation. Blocks until the user responds (or times out). */
__block bool granted = false;
dispatch_semaphore_t sem = dispatch_semaphore_create(0);
[[UNUserNotificationCenter currentNotificationCenter]
requestAuthorizationWithOptions:(UNAuthorizationOptionAlert
| UNAuthorizationOptionSound
| UNAuthorizationOptionBadge)
completionHandler:^(BOOL g, NSError* __unused err) {
granted = (bool)g;
dispatch_semaphore_signal(sem);
}];
dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, 30 * NSEC_PER_SEC));
g_initialized = granted;
return granted;
}
void MNW_Uninitialize(void)
{
if (!g_lock) return;
/* Remove all notifications posted by this process. */
UNUserNotificationCenter* center = [UNUserNotificationCenter currentNotificationCenter];
[center removeAllPendingNotificationRequests];
[center removeAllDeliveredNotifications];
/* Free all live state without firing callbacks. */
[g_lock lock];
for (NSValue* val in g_entries.allValues) {
NotifState* s = (NotifState*)[val pointerValue];
free(s);
}
[g_entries removeAllObjects];
[g_idMap removeAllObjects];
[g_lock unlock];
g_initialized = false;
}
int64_t MNW_ShowNotification(
const MNW_NotificationDescriptor* descriptor,
const MNW_Handler* handler)
{
if (!descriptor || !handler) return -1;
if (!descriptor->title || descriptor->title[0]=='\0') return -2;
if (!g_lock) return -3; /* Not initialised */
int64_t notifId = atomic_fetch_add_explicit(&g_counter, 1, memory_order_relaxed);
NSString* strId = StringIdFor(notifId);
/* --- Content --------------------------------------------------------- */
UNMutableNotificationContent* content = [[UNMutableNotificationContent alloc] init];
content.title = [NSString stringWithUTF8String:descriptor->title];
if (descriptor->body && descriptor->body[0] != '\0')
content.body = [NSString stringWithUTF8String:descriptor->body];
/* --- Sound ----------------------------------------------------------- */
content.sound = (descriptor->audioOption == MNW_AUDIO_SILENT)
? nil
: [UNNotificationSound defaultSound];
/* --- Interruption level (macOS 12+) ---------------------------------- */
if (@available(macOS 12.0, *)) {
switch (descriptor->interruptionLevel) {
case MNW_INTERRUPTION_PASSIVE:
content.interruptionLevel = UNNotificationInterruptionLevelPassive;
break;
case MNW_INTERRUPTION_TIME_SENSITIVE:
content.interruptionLevel = UNNotificationInterruptionLevelTimeSensitive;
break;
case MNW_INTERRUPTION_CRITICAL:
content.interruptionLevel = UNNotificationInterruptionLevelCritical;
break;
default:
content.interruptionLevel = UNNotificationInterruptionLevelActive;
break;
}
}
/* --- Image attachment ------------------------------------------------ */
if (descriptor->imagePath && descriptor->imagePath[0] != '\0') {
NSString* path = [NSString stringWithUTF8String:descriptor->imagePath];
NSURL* url = [NSURL fileURLWithPath:path];
NSError* err = nil;
UNNotificationAttachment* att = [UNNotificationAttachment
attachmentWithIdentifier:@"image" URL:url options:nil error:&err];
if (att)
content.attachments = @[att];
/* If attachment fails (file missing, unsupported format), continue without it. */
}
/* --- Category / buttons --------------------------------------------- */
content.categoryIdentifier = EnsureCategory(descriptor->buttonLabels,
descriptor->buttonCount);
/* --- Trigger --------------------------------------------------------- */
/*
* UNTimeIntervalNotificationTrigger requires timeInterval > 0.
* Use 0.1 s for "fire immediately" — imperceptible to the user.
*/
UNTimeIntervalNotificationTrigger* trigger =
[UNTimeIntervalNotificationTrigger triggerWithTimeInterval:0.1 repeats:NO];
/* --- Register state BEFORE scheduling so no callback is missed ------- */
StoreEntry(strId, notifId, handler);
/* --- Schedule -------------------------------------------------------- */
UNNotificationRequest* req = [UNNotificationRequest
requestWithIdentifier:strId
content:content
trigger:trigger];
[[UNUserNotificationCenter currentNotificationCenter]
addNotificationRequest:req
withCompletionHandler:^(NSError* error) {
if (!error) return;
/* Delivery failed — fire onFailed and clean up. */
NotifState state = {0};
if (TakeEntry(strId, &state)) {
if (state.handler.onFailed)
state.handler.onFailed(state.notifId);
}
}];
return notifId;
}
bool MNW_HideNotification(int64_t notifId)
{
if (!g_lock) return false;
[g_lock lock];
NSString* strId = g_idMap[@(notifId)];
[g_lock unlock];
if (!strId) return false;
NSArray<NSString*>* ids = @[strId];
UNUserNotificationCenter* center = [UNUserNotificationCenter currentNotificationCenter];
[center removePendingNotificationRequestsWithIdentifiers:ids];
[center removeDeliveredNotificationsWithIdentifiers:ids];
/* Fire dismissed callback synchronously before returning. */
NotifState state = {0};
if (TakeEntry(strId, &state)) {
if (state.handler.onDismissed)
state.handler.onDismissed(state.notifId, MNW_DISMISS_APP_REMOVED);
}
return true;
}
/* -------------------------------------------------------------------------
* Dock-tile progress
*
* AppKit Dock APIs are main-thread-only, so all work is dispatched onto the
* main queue. The custom content view draws the application icon with an
* NSProgressIndicator overlaid along the bottom edge.
* ------------------------------------------------------------------------- */
/* Accessed only on the main thread (inside the dispatched block). */
static NSImageView* g_dockImageView = nil;
static NSProgressIndicator* g_dockProgress = nil;
static void EnsureDockViews(NSDockTile* tile)
{
if (g_dockImageView) return;
NSImageView* iconView = [[NSImageView alloc]
initWithFrame:NSMakeRect(0, 0, tile.size.width, tile.size.height)];
iconView.image = [NSApp applicationIconImage];
NSProgressIndicator* bar = [[NSProgressIndicator alloc]
initWithFrame:NSMakeRect(0.0, 0.0, tile.size.width, 12.0)];
bar.style = NSProgressIndicatorStyleBar;
bar.indeterminate = NO;
bar.minValue = 0.0;
bar.maxValue = 1.0;
[iconView addSubview:bar];
tile.contentView = iconView;
g_dockImageView = iconView;
g_dockProgress = bar;
}
void MNW_SetTaskbarProgress(int state, double fraction)
{
dispatch_async(dispatch_get_main_queue(), ^{
NSApplication* app = [NSApplication sharedApplication];
NSDockTile* tile = [app dockTile];
if (state == MNW_PROGRESS_NONE) {
if (g_dockProgress) [g_dockProgress stopAnimation:nil];
tile.contentView = nil;
g_dockImageView = nil;
g_dockProgress = nil;
[tile display];
return;
}
EnsureDockViews(tile);
if (state == MNW_PROGRESS_INDETERMINATE) {
g_dockProgress.indeterminate = YES;
[g_dockProgress startAnimation:nil];
} else {
[g_dockProgress stopAnimation:nil];
g_dockProgress.indeterminate = NO;
double clamped = fraction < 0.0 ? 0.0 : (fraction > 1.0 ? 1.0 : fraction);
g_dockProgress.doubleValue = clamped;
}
g_dockProgress.hidden = NO;
[tile display];
});
}
/* -------------------------------------------------------------------------
* Dock menu (jump-list equivalent)
*
* Custom Dock-menu items are supplied to AppKit through the application
* delegate's -applicationDockMenu:. Unlike Windows/Linux this fires a live
* in-process callback — there is no relaunch.
*
* All AppKit objects below are touched only on the main thread (inside the
* dispatched blocks); the C callback pointer is read/written under g_dockLock.
* ------------------------------------------------------------------------- */
/* Built/replaced on the main thread; read by -applicationDockMenu: on the main thread. */
static NSMenu* g_dockMenu = nil;
/* Guards g_dockCb only (the menu is confined to the main thread). */
static NSLock* g_dockLock = nil;
static MNW_DockMenuCallback g_dockCb = NULL;
/* Target object for the menu items; routes -onItem: to the managed callback. */
@interface MNWDockTarget : NSObject
- (void)onItem:(id)sender;
@end
@implementation MNWDockTarget
- (void)onItem:(id)sender
{
NSString* taskId = nil;
if ([sender respondsToSelector:@selector(representedObject)])
taskId = [sender representedObject];
if (![taskId isKindOfClass:[NSString class]]) return;
[g_dockLock lock];
MNW_DockMenuCallback cb = g_dockCb;
[g_dockLock unlock];
if (cb) cb([taskId UTF8String]);
}
@end
/* A minimal delegate used only when the host application has no delegate of its own. */
@interface MNWDockDelegate : NSObject <NSApplicationDelegate>
@end
@implementation MNWDockDelegate
- (NSMenu*)applicationDockMenu:(NSApplication*)sender
{
(void)sender;
return g_dockMenu;
}
@end
static MNWDockTarget* g_dockTarget = nil;
static MNWDockDelegate* g_dockDelegate = nil;
/*
* Implementation injected into a pre-existing delegate's class (via class_addMethod)
* when that delegate does not already implement -applicationDockMenu:.
*/
static NSMenu* mnw_dock_menu_imp(id self, SEL _cmd, NSApplication* sender)
{
(void)self; (void)_cmd; (void)sender;
return g_dockMenu;
}
/*
* Ensures AppKit will call back into us for the Dock menu. Must run on the main thread.
* - If the app has no delegate, install ours.
* - If it has one that already implements -applicationDockMenu:, leave it alone
* (we cannot compose with the app's own menu without overriding it).
* - Otherwise add -applicationDockMenu: to the existing delegate's class.
*/
static void EnsureDockDelegate(void)
{
NSApplication* app = [NSApplication sharedApplication];
id existing = [app delegate];
if (existing == nil) {
if (!g_dockDelegate) g_dockDelegate = [[MNWDockDelegate alloc] init];
[app setDelegate:g_dockDelegate];
return;
}
if ([existing respondsToSelector:@selector(applicationDockMenu:)])
return; /* The host already provides a Dock menu; do not clobber it. */
class_addMethod(object_getClass(existing),
@selector(applicationDockMenu:),
(IMP)mnw_dock_menu_imp,
"@@:@");
}
static void EnsureDockGlobals(void)
{
static dispatch_once_t once;
dispatch_once(&once, ^{
g_dockLock = [[NSLock alloc] init];
g_dockTarget = [[MNWDockTarget alloc] init];
});
}
void MNW_SetDockMenuHandler(MNW_DockMenuCallback callback)
{
EnsureDockGlobals();
[g_dockLock lock];
g_dockCb = callback;
[g_dockLock unlock];
}
void MNW_SetDockMenu(const char** ids, const char** titles, int count)
{
EnsureDockGlobals();
/* Copy the C strings into NSStrings synchronously; the caller may free the
* arrays as soon as this function returns. */
NSMutableArray<NSString*>* idArr = [NSMutableArray arrayWithCapacity:(count > 0 ? count : 0)];
NSMutableArray<NSString*>* titleArr = [NSMutableArray arrayWithCapacity:(count > 0 ? count : 0)];
for (int i = 0; i < count; i++) {
const char* idC = ids ? ids[i] : NULL;
const char* titleC = titles ? titles[i] : NULL;
if (!idC || !titleC) continue;
[idArr addObject:[NSString stringWithUTF8String:idC]];
[titleArr addObject:[NSString stringWithUTF8String:titleC]];
}
dispatch_async(dispatch_get_main_queue(), ^{
if (idArr.count == 0) {
g_dockMenu = nil;
EnsureDockDelegate();
return;
}
NSMenu* menu = [[NSMenu alloc] init];
for (NSUInteger i = 0; i < idArr.count; i++) {
NSMenuItem* item = [[NSMenuItem alloc]
initWithTitle:titleArr[i]
action:@selector(onItem:)
keyEquivalent:@""];
item.target = g_dockTarget;
item.representedObject = idArr[i];
[menu addItem:item];
}
g_dockMenu = menu;
EnsureDockDelegate();
});
}
void MNW_ClearDockMenu(void)
{
MNW_SetDockMenu(NULL, NULL, 0);
}