better interop with obj-c

This commit is contained in:
Zach Botterman 2025-02-26 18:45:46 -08:00
commit a807c269cc
5 changed files with 183 additions and 74 deletions

View file

@ -1,12 +1,17 @@
package notifications
import "sync"
// Service represents the notifications service
type Service struct {
// Callback is called when a notification response is received
Callback func(response NotificationResponse)
// notificationResponseCallback is called when a notification response is received
notificationResponseCallback func(response NotificationResponse)
callbackLock sync.RWMutex
}
var NotificationService *Service
var notificationServiceLock sync.RWMutex
// NotificationAction represents an action button for a notification
type NotificationAction = struct {
@ -54,18 +59,22 @@ func (ns *Service) ServiceName() string {
}
// OnNotificationResponse registers a callback function that will be called when
// a notification response is received from the user
// a notification response is received from the user.
func (ns *Service) OnNotificationResponse(callback func(response NotificationResponse)) {
if ns.Callback != nil {
return
}
ns.Callback = callback
ns.callbackLock.Lock()
defer ns.callbackLock.Unlock()
ns.notificationResponseCallback = callback
}
// handleNotificationResponse is an internal method to handle notification responses
// and invoke the registered callback if one exists
func (ns *Service) handleNotificationResponse(response NotificationResponse) {
if ns.Callback != nil {
ns.Callback(response)
ns.callbackLock.RLock()
callback := ns.notificationResponseCallback
ns.callbackLock.RUnlock()
if callback != nil {
callback(response)
}
}

View file

@ -12,16 +12,32 @@ import (
"context"
"encoding/json"
"fmt"
"sync"
"unsafe"
"github.com/wailsapp/wails/v3/pkg/application"
)
var AppleDefaultActionIdentifier = "com.apple.UNNotificationDefaultActionIdentifier"
type notificationChannel struct {
authorized bool
err error
}
var (
notificationChannels = make(map[int]chan notificationChannel)
notificationChannelsLock sync.Mutex
nextChannelID int
)
const AppleDefaultActionIdentifier = "com.apple.UNNotificationDefaultActionIdentifier"
const BundleIdentifierError = fmt.Errorf("notifications require a bundled application with a unique bundle identifier")
// Creates a new Notifications Service.
// Your app must be packaged and signed for this feature to work.
func New() *Service {
notificationServiceLock.Lock()
defer notificationServiceLock.Unlock()
if NotificationService == nil {
NotificationService = &Service{}
}
@ -31,7 +47,7 @@ func New() *Service {
// ServiceStartup is called when the service is loaded.
func (ns *Service) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
if !CheckBundleIdentifier() {
return fmt.Errorf("notifications require a bundled application with a unique bundle identifier")
return BundleIdentifierError
}
return nil
}
@ -45,21 +61,32 @@ func CheckBundleIdentifier() bool {
return bool(C.checkBundleIdentifier())
}
// RequestUserNotificationAuthorization requests permission for notifications.
func (ns *Service) RequestUserNotificationAuthorization() (bool, error) {
// RequestNotificationAuthorization requests permission for notifications.
func (ns *Service) RequestNotificationAuthorization() (bool, err) {
if !CheckBundleIdentifier() {
return false, fmt.Errorf("notifications require a bundled application with a unique bundle identifier")
return false, BundleIdentifierError
}
result := C.requestUserNotificationAuthorization(nil)
return result == true, nil
id, resultCh := registerChannel()
C.requestNotificationAuthorization(C.int(id))
result := <-resultCh
return result.authorized, result.err
}
// CheckNotificationAuthorization checks current notification permission status.
func (ns *Service) CheckNotificationAuthorization() (bool, error) {
func (ns *Service) CheckNotificationAuthorization() (bool, err) {
if !CheckBundleIdentifier() {
return false, fmt.Errorf("notifications require a bundled application with a unique bundle identifier")
return false, BundleIdentifierError
}
return bool(C.checkNotificationAuthorization()), nil
id, resultCh := registerChannel()
C.checkNotificationAuthorization(C.int(id))
result := <-resultCh
return result.authorized, result.err
}
// SendNotification sends a basic notification with a unique identifier, title, subtitle, and body.
@ -212,9 +239,48 @@ func (ns *Service) RemoveNotification(identifier string) error {
return nil
}
// SetIcon is a macOS stub. The application icon is used automatically.
func (ns *Service) SetIcon(_ []byte) {
return
//export requestNotificationAuthorizationResponse
func requestNotificationAuthorizationResponse(channelID C.int, authorized C.bool, errorMsg *C.char) {
resultCh, exists := getAndDeleteChannel(int(channelID))
if !exists {
// handle this
return
}
var err error
if errorMsg != nil {
err = fmt.Errorf("%s", C.GoString(errorMsg))
C.free(unsafe.Pointer(errorMsg))
}
resultCh <- notificationChannel{
authorized: bool(authorized),
err: err,
}
close(resultCh)
}
//export checkNotificationAuthorizationResponse
func checkNotificationAuthorizationResponse(channelID C.int, authorized C.bool, errorMsg *C.char) {
resultCh, exists := getAndDeleteChannel(int(channelID))
if !exists {
// handle this
return
}
var err error
if errorMsg != nil {
err = fmt.Errorf("%s", C.GoString(errorMsg))
C.free(unsafe.Pointer(errorMsg))
}
resultCh <- notificationChannel{
authorized: bool(authorized),
err: err,
}
close(resultCh)
}
//export didReceiveNotificationResponse
@ -230,7 +296,44 @@ func didReceiveNotificationResponse(jsonPayload *C.char) {
response.ActionIdentifier = DefaultActionIdentifier
}
if NotificationService != nil {
NotificationService.handleNotificationResponse(response)
notificationServiceLock.RLock()
ns := NotificationService
notificationServiceLock.RUnlock()
if service != nil {
ns.callbackLock.RLock()
callback := ns.notificationResponseCallback
ns.callbackLock.RUnlock()
if callback != nil {
callback(response)
}
}
}
func registerChannel() (int, chan notificationChannel) {
notificationChannelsLock.Lock()
defer notificationChannelsLock.Unlock()
id := nextChannelID
nextChannelID++
resultCh := make(chan notificationChannel{
authorized bool
err error
}, 1)
notificationChannels[id] = resultCh
return id, resultCh
}
func getChannel(id int) (chan notificationChannel, bool) {
notificationChannelsLock.Lock()
defer notificationChannelsLock.Unlock()
ch, exists := notificationChannels[id]
if exists {
delete(notificationChannels, id)
}
return ch, exists
}

View file

@ -6,8 +6,8 @@
#import <Foundation/Foundation.h>
bool checkBundleIdentifier(void);
bool requestUserNotificationAuthorization(void *completion);
bool checkNotificationAuthorization(void);
bool requestNotificationAuthorization(void *channelPtr);
bool checkNotificationAuthorization(void *channelPtr);
void sendNotification(const char *identifier, const char *title, const char *subtitle, const char *body, const char *data_json, void *completion);
void sendNotificationWithActions(const char *identifier, const char *title, const char *subtitle, const char *body, const char *categoryId, const char *actions_json, void *completion);
void registerNotificationCategory(const char *categoryId, const char *actions_json, bool hasReplyField, const char *replyPlaceholder, const char *replyButtonTitle);
@ -17,4 +17,8 @@ void removePendingNotification(const char *identifier);
void removeAllDeliveredNotifications(void);
void removeDeliveredNotification(const char *identifier);
extern void requestNotificationAuthorizationResponse(int channelID, bool authorized, const char* error);
extern void checkNotificationAuthorizationResponse(int channelID, bool authorized, const char* error);
extern void didReceiveNotificationResponse(const char *jsonPayload);
#endif /* NOTIFICATIONS_DARWIN_H */

View file

@ -2,8 +2,6 @@
#import <Cocoa/Cocoa.h>
#import <UserNotifications/UserNotifications.h>
extern void didReceiveNotificationResponse(const char *jsonPayload);
@interface NotificationsDelegate : NSObject <UNUserNotificationCenterDelegate>
@end
@ -71,8 +69,8 @@ static void ensureDelegateInitialized(void) {
bool checkBundleIdentifier(void) {
NSBundle *main = [NSBundle mainBundle];
if (main.bundleIdentifier == nil) {
NSLog(@"Error: Cannot use notifications in development mode.\n"
" Notifications require the app to be properly bundled with a bundle identifier.\n"
NSLog(@"Error: Cannot use the notification API in development mode.\n"
" Notifications require the app to be properly bundled with a bundle identifier and signed.\n"
" To test notifications:\n"
" 1. Build and package your app using 'wails3 package'\n"
" 2. Sign the packaged .app\n"
@ -82,40 +80,32 @@ bool checkBundleIdentifier(void) {
return true;
}
bool requestUserNotificationAuthorization(void *completion) {
if (!checkBundleIdentifier()) {
return false;
}
bool requestNotificationAuthorization(int channelID) {
ensureDelegateInitialized();
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
UNAuthorizationOptions options = UNAuthorizationOptionAlert | UNAuthorizationOptionSound | UNAuthorizationOptionBadge;
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
UNAuthorizationOptions options = UNAuthorizationOptionAlert | UNAuthorizationOptionSound | UNAuthorizationOptionBadge;
[center requestAuthorizationWithOptions:options completionHandler:^(BOOL granted, NSError * _Nullable error) {
if (completion != NULL) {
void (^callback)(NSError *, BOOL) = completion;
callback(error, granted);
if (error) {
requestNotificationAuthorizationResponse(channelID, false, [[error localizedDescription] UTF8String]);
} else {
requestNotificationAuthorizationResponse(channelID, granted, NULL);
}
}];
return true;
}
bool checkNotificationAuthorization(void) {
bool checkNotificationAuthorization(int channelID) {
ensureDelegateInitialized();
__block BOOL isAuthorized = NO;
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
[center getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings * _Nonnull settings) {
isAuthorized = (settings.authorizationStatus == UNAuthorizationStatusAuthorized);
dispatch_semaphore_signal(semaphore);
[center getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings *settings) {
BOOL isAuthorized = (settings.authorizationStatus == UNAuthorizationStatusAuthorized);
checkNotificationAuthorizationResponse(channelID, isAuthorized, NULL);
}];
// Wait for response with a timeout
dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 3 * NSEC_PER_SEC));
return isAuthorized;
}
void sendNotification(const char *identifier, const char *title, const char *subtitle, const char *body, const char *data_json, void *completion) {

View file

@ -20,11 +20,11 @@ import (
)
var (
NotificationLock sync.RWMutex
NotificationCategories = make(map[string]NotificationCategory)
AppName string
AppGUID string
IconPath string
NotificationCategories = make(map[string]NotificationCategory)
notificationCategoriesLock sync.RWMutex
appName string
appGUID string
iconPath string
)
const (
@ -42,6 +42,9 @@ type NotificationPayload struct {
// Creates a new Notifications Service.
func New() *Service {
notificationServiceLock.Lock()
defer notificationServiceLock.Unlock()
if NotificationService == nil {
NotificationService = &Service{}
}
@ -51,20 +54,20 @@ func New() *Service {
// ServiceStartup is called when the service is loaded
// Sets an activation callback to emit an event when notifications are interacted with.
func (ns *Service) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
AppName = application.Get().Config().Name
appName = application.Get().Config().Name
guid, err := getGUID()
if err != nil {
return err
}
AppGUID = guid
appGUID = guid
IconPath = filepath.Join(os.TempDir(), AppName+guid+".png")
iconPath = filepath.Join(os.TempDir(), appName+appGUID+".png")
toast.SetAppData(toast.AppData{
AppID: AppName,
AppID: appName,
GUID: guid,
IconPath: IconPath,
IconPath: iconPath,
})
toast.SetActivationCallback(func(args string, data []toast.UserData) {
@ -147,9 +150,9 @@ func (ns *Service) SendNotificationWithActions(options NotificationOptions) erro
fmt.Printf("Error saving icon: %v\n", err)
}
NotificationLock.RLock()
notificationCategoriesLock.RLock()
nCategory := NotificationCategories[options.CategoryID]
NotificationLock.RUnlock()
notificationCategoriesLock.RUnlock()
n := toast.Notification{
Title: options.Title,
@ -195,7 +198,7 @@ func (ns *Service) SendNotificationWithActions(options NotificationOptions) erro
// RegisterNotificationCategory registers a new NotificationCategory to be used with SendNotificationWithActions.
// Registering a category with the same name as a previously registered NotificationCategory will override it.
func (ns *Service) RegisterNotificationCategory(category NotificationCategory) error {
NotificationLock.Lock()
notificationCategoriesLock.Lock()
NotificationCategories[category.ID] = NotificationCategory{
ID: category.ID,
Actions: category.Actions,
@ -203,16 +206,16 @@ func (ns *Service) RegisterNotificationCategory(category NotificationCategory) e
ReplyPlaceholder: category.ReplyPlaceholder,
ReplyButtonTitle: category.ReplyButtonTitle,
}
NotificationLock.Unlock()
notificationCategoriesLock.Unlock()
return saveCategoriesToRegistry()
}
// RemoveNotificationCategory removes a previously registered NotificationCategory.
func (ns *Service) RemoveNotificationCategory(categoryId string) error {
NotificationLock.Lock()
notificationCategoriesLock.Lock()
delete(NotificationCategories, categoryId)
NotificationLock.Unlock()
notificationCategoriesLock.Unlock()
return saveCategoriesToRegistry()
}
@ -298,11 +301,11 @@ func saveIconToDir() error {
return fmt.Errorf("failed to retrieve application icon")
}
return saveHIconAsPNG(icon, IconPath)
return saveHIconAsPNG(icon, iconPath)
}
func saveCategoriesToRegistry() error {
registryPath := fmt.Sprintf(NotificationCategoriesRegistryPath, AppName)
registryPath := fmt.Sprintf(NotificationCategoriesRegistryPath, appName)
key, _, err := registry.CreateKey(
registry.CURRENT_USER,
@ -314,9 +317,9 @@ func saveCategoriesToRegistry() error {
}
defer key.Close()
NotificationLock.RLock()
notificationCategoriesLock.RLock()
data, err := json.Marshal(NotificationCategories)
NotificationLock.RUnlock()
notificationCategoriesLock.RUnlock()
if err != nil {
return err
}
@ -325,7 +328,7 @@ func saveCategoriesToRegistry() error {
}
func loadCategoriesFromRegistry() error {
registryPath := fmt.Sprintf(NotificationCategoriesRegistryPath, AppName)
registryPath := fmt.Sprintf(NotificationCategoriesRegistryPath, appName)
key, err := registry.OpenKey(
registry.CURRENT_USER,
@ -350,9 +353,9 @@ func loadCategoriesFromRegistry() error {
return err
}
NotificationLock.Lock()
notificationCategoriesLock.Lock()
NotificationCategories = categories
NotificationLock.Unlock()
notificationCategoriesLock.Unlock()
return nil
}
@ -367,7 +370,7 @@ func getUserText(data []toast.UserData) (string, bool) {
}
func getGUID() (string, error) {
keyPath := ToastRegistryPath + AppName
keyPath := ToastRegistryPath + appName
k, err := registry.OpenKey(registry.CURRENT_USER, keyPath, registry.QUERY_VALUE)
if err == nil {