This commit is contained in:
Zach Botterman 2026-03-11 13:40:58 -07:00 committed by GitHub
commit b9086fa316
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 3722 additions and 9 deletions

View file

@ -51,6 +51,7 @@ require (
atomicgo.dev/keyboard v0.2.9 // indirect
atomicgo.dev/schedule v0.1.0 // indirect
dario.cat/mergo v1.0.0 // indirect
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/ProtonMail/go-crypto v1.1.5 // indirect
github.com/alecthomas/chroma/v2 v2.14.0 // indirect

View file

@ -8,6 +8,8 @@ atomicgo.dev/schedule v0.1.0 h1:nTthAbhZS5YZmgYbb2+DH8uQIZcTlIrd4eYr3UQxEjs=
atomicgo.dev/schedule v0.1.0/go.mod h1:xeUa3oAkiuHYh8bKiQBRojqAMq3PXXbJujjb0hw8pEU=
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/MarvinJWendt/testza v0.1.0/go.mod h1:7AxNvlfeHP7Z/hDQ5JtE3OKYT3XFUeLCDE2DQninSqs=
github.com/MarvinJWendt/testza v0.2.1/go.mod h1:God7bhG8n6uQxwdScay+gjm9/LnO4D3kkcZX4hv9Rp8=
github.com/MarvinJWendt/testza v0.2.8/go.mod h1:nwIcjmr0Zz+Rcwfh3/4UhBp7ePKVhuBExvZqnKYWlII=

View file

@ -69,6 +69,21 @@ void UpdateMenuItem(void* nsmenuitem, int checked);
void RunMainLoop(void);
void ReleaseContext(void *inctx);
/* Notifications */
bool IsNotificationAvailable(void *inctx);
bool CheckBundleIdentifier(void *inctx);
bool EnsureDelegateInitialized(void *inctx);
void RequestNotificationAuthorization(void *inctx, int channelID);
void CheckNotificationAuthorization(void *inctx, int channelID);
void SendNotification(void *inctx, int channelID, const char *identifier, const char *title, const char *subtitle, const char *body, const char *data_json);
void SendNotificationWithActions(void *inctx, int channelID, const char *identifier, const char *title, const char *subtitle, const char *body, const char *categoryId, const char *actions_json);
void RegisterNotificationCategory(void *inctx, int channelID, const char *categoryId, const char *actions_json, bool hasReplyField, const char *replyPlaceholder, const char *replyButtonTitle);
void RemoveNotificationCategory(void *inctx, int channelID, const char *categoryId);
void RemoveAllPendingNotifications(void *inctx);
void RemovePendingNotification(void *inctx, const char *identifier);
void RemoveAllDeliveredNotifications(void *inctx);
void RemoveDeliveredNotification(void *inctx, const char *identifier);
NSString* safeInit(const char* input);
#endif /* Application_h */

View file

@ -367,6 +367,74 @@ void AppendSeparator(void* inMenu) {
}
bool IsNotificationAvailable(void *inctx) {
WailsContext *ctx = (__bridge WailsContext*)inctx;
return [ctx IsNotificationAvailable];
}
bool CheckBundleIdentifier(void *inctx) {
WailsContext *ctx = (__bridge WailsContext*)inctx;
return [ctx CheckBundleIdentifier];
}
bool EnsureDelegateInitialized(void *inctx) {
WailsContext *ctx = (__bridge WailsContext*)inctx;
return [ctx EnsureDelegateInitialized];
}
void RequestNotificationAuthorization(void *inctx, int channelID) {
WailsContext *ctx = (__bridge WailsContext*)inctx;
[ctx RequestNotificationAuthorization:channelID];
}
void CheckNotificationAuthorization(void *inctx, int channelID) {
WailsContext *ctx = (__bridge WailsContext*)inctx;
[ctx CheckNotificationAuthorization:channelID];
}
void SendNotification(void *inctx, int channelID, const char *identifier, const char *title, const char *subtitle, const char *body, const char *data_json) {
WailsContext *ctx = (__bridge WailsContext*)inctx;
[ctx SendNotification:channelID :identifier :title :subtitle :body :data_json];
}
void SendNotificationWithActions(void *inctx, int channelID, const char *identifier, const char *title, const char *subtitle, const char *body, const char *categoryId, const char *actions_json) {
WailsContext *ctx = (__bridge WailsContext*)inctx;
[ctx SendNotificationWithActions:channelID :identifier :title :subtitle :body :categoryId :actions_json];
}
void RegisterNotificationCategory(void *inctx, int channelID, const char *categoryId, const char *actions_json, bool hasReplyField, const char *replyPlaceholder, const char *replyButtonTitle) {
WailsContext *ctx = (__bridge WailsContext*)inctx;
[ctx RegisterNotificationCategory:channelID :categoryId :actions_json :hasReplyField :replyPlaceholder :replyButtonTitle];
}
void RemoveNotificationCategory(void *inctx, int channelID, const char *categoryId) {
WailsContext *ctx = (__bridge WailsContext*)inctx;
[ctx RemoveNotificationCategory:channelID :categoryId];
}
void RemoveAllPendingNotifications(void *inctx) {
WailsContext *ctx = (__bridge WailsContext*)inctx;
[ctx RemoveAllPendingNotifications];
}
void RemovePendingNotification(void *inctx, const char *identifier) {
WailsContext *ctx = (__bridge WailsContext*)inctx;
[ctx RemovePendingNotification:identifier];
}
void RemoveAllDeliveredNotifications(void *inctx) {
WailsContext *ctx = (__bridge WailsContext*)inctx;
[ctx RemoveAllDeliveredNotifications];
}
void RemoveDeliveredNotification(void *inctx, const char *identifier) {
WailsContext *ctx = (__bridge WailsContext*)inctx;
[ctx RemoveDeliveredNotification:identifier];
}
void Run(void *inctx, const char* url) {
WailsContext *ctx = (__bridge WailsContext*) inctx;

View file

@ -92,10 +92,24 @@ struct Preferences {
- (void) ShowApplication;
- (void) Quit;
-(void) MessageDialog :(NSString*)dialogType :(NSString*)title :(NSString*)message :(NSString*)button1 :(NSString*)button2 :(NSString*)button3 :(NSString*)button4 :(NSString*)defaultButton :(NSString*)cancelButton :(void*)iconData :(int)iconDataLength;
- (void) MessageDialog :(NSString*)dialogType :(NSString*)title :(NSString*)message :(NSString*)button1 :(NSString*)button2 :(NSString*)button3 :(NSString*)button4 :(NSString*)defaultButton :(NSString*)cancelButton :(void*)iconData :(int)iconDataLength;
- (void) OpenFileDialog :(NSString*)title :(NSString*)defaultFilename :(NSString*)defaultDirectory :(bool)allowDirectories :(bool)allowFiles :(bool)canCreateDirectories :(bool)treatPackagesAsDirectories :(bool)resolveAliases :(bool)showHiddenFiles :(bool)allowMultipleSelection :(NSString*)filters;
- (void) SaveFileDialog :(NSString*)title :(NSString*)defaultFilename :(NSString*)defaultDirectory :(bool)canCreateDirectories :(bool)treatPackagesAsDirectories :(bool)showHiddenFiles :(NSString*)filters;
- (bool) IsNotificationAvailable;
- (bool) CheckBundleIdentifier;
- (bool) EnsureDelegateInitialized;
- (void) RequestNotificationAuthorization:(int)channelID;
- (void) CheckNotificationAuthorization:(int)channelID;
- (void) SendNotification:(int)channelID :(const char *)identifier :(const char *)title :(const char *)subtitle :(const char *)body :(const char *)dataJSON;
- (void) SendNotificationWithActions:(int)channelID :(const char *)identifier :(const char *)title :(const char *)subtitle :(const char *)body :(const char *)categoryId :(const char *)actionsJSON;
- (void) RegisterNotificationCategory:(int)channelID :(const char *)categoryId :(const char *)actionsJSON :(bool)hasReplyField :(const char *)replyPlaceholder :(const char *)replyButtonTitle;
- (void) RemoveNotificationCategory:(int)channelID :(const char *)categoryId;
- (void) RemoveAllPendingNotifications;
- (void) RemovePendingNotification:(const char *)identifier;
- (void) RemoveAllDeliveredNotifications;
- (void) RemoveDeliveredNotification:(const char *)identifier;
- (void) loadRequest:(NSString*)url;
- (void) ExecJS:(NSString*)script;
- (NSScreen*) getCurrentScreen;

View file

@ -5,6 +5,7 @@
// Created by Lea Anthony on 10/10/21.
//
#include "Application.h"
#import <Foundation/Foundation.h>
#import <WebKit/WebKit.h>
#import "WailsContext.h"
@ -36,6 +37,14 @@ typedef void (^schemeTaskCaller)(id<WKURLSchemeTask>);
@end
// Notifications
#if __MAC_OS_X_VERSION_MAX_ALLOWED >= 101400
#import <UserNotifications/UserNotifications.h>
#endif
extern void captureResult(int channelID, bool success, const char* error);
extern void didReceiveNotificationResponse(const char *jsonPayload, const char* error);
@implementation WailsContext
- (void) SetSize:(int)width :(int)height {
@ -723,6 +732,357 @@ typedef void (^schemeTaskCaller)(id<WKURLSchemeTask>);
}
/***** Notifications ******/
- (bool) IsNotificationAvailable {
if (@available(macOS 10.14, *)) {
return YES;
} else {
return NO;
}
}
- (bool) CheckBundleIdentifier {
NSBundle *main = [NSBundle mainBundle];
if (main.bundleIdentifier == nil) {
return NO;
}
return YES;
}
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
willPresentNotification:(UNNotification *)notification
withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler API_AVAILABLE(macos(10.14)) {
UNNotificationPresentationOptions options = UNNotificationPresentationOptionSound;
if (@available(macOS 11.0, *)) {
// These options are only available in macOS 11.0+
options = UNNotificationPresentationOptionList |
UNNotificationPresentationOptionBanner |
UNNotificationPresentationOptionSound;
}
completionHandler(options);
}
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
didReceiveNotificationResponse:(UNNotificationResponse *)response
withCompletionHandler:(void (^)(void))completionHandler API_AVAILABLE(macos(10.14)) {
NSMutableDictionary *payload = [NSMutableDictionary dictionary];
[payload setObject:response.notification.request.identifier forKey:@"id"];
[payload setObject:response.actionIdentifier forKey:@"actionIdentifier"];
[payload setObject:response.notification.request.content.title ?: @"" forKey:@"title"];
[payload setObject:response.notification.request.content.body ?: @"" forKey:@"body"];
if (response.notification.request.content.categoryIdentifier) {
[payload setObject:response.notification.request.content.categoryIdentifier forKey:@"categoryId"];
}
if (response.notification.request.content.subtitle) {
[payload setObject:response.notification.request.content.subtitle forKey:@"subtitle"];
}
if (response.notification.request.content.userInfo) {
[payload setObject:response.notification.request.content.userInfo forKey:@"userInfo"];
}
if ([response isKindOfClass:[UNTextInputNotificationResponse class]]) {
UNTextInputNotificationResponse *textResponse = (UNTextInputNotificationResponse *)response;
[payload setObject:textResponse.userText forKey:@"userText"];
}
NSError *error = nil;
NSData *jsonData = [NSJSONSerialization dataWithJSONObject:payload options:0 error:&error];
if (error) {
NSString *errorMsg = [NSString stringWithFormat:@"Error: %@", [error localizedDescription]];
didReceiveNotificationResponse(NULL, [errorMsg UTF8String]);
} else {
NSString *jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding];
didReceiveNotificationResponse([jsonString UTF8String], NULL);
}
completionHandler();
}
- (bool) EnsureDelegateInitialized {
if (@available(macOS 10.14, *)) {
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
center.delegate = (id<UNUserNotificationCenterDelegate>)self;
return YES;
}
return NO;
}
- (void) RequestNotificationAuthorization :(int)channelID {
if (@available(macOS 10.14, *)) {
if (![self EnsureDelegateInitialized]) {
NSString *errorMsg = @"Notification delegate has been lost. Reinitialize the notification service.";
captureResult(channelID, false, [errorMsg UTF8String]);
return;
}
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
UNAuthorizationOptions options = UNAuthorizationOptionAlert | UNAuthorizationOptionSound | UNAuthorizationOptionBadge;
[center requestAuthorizationWithOptions:options completionHandler:^(BOOL granted, NSError * _Nullable error) {
if (error) {
NSString *errorMsg = [NSString stringWithFormat:@"Error: %@", [error localizedDescription]];
captureResult(channelID, false, [errorMsg UTF8String]);
} else {
captureResult(channelID, granted, NULL);
}
}];
} else {
captureResult(channelID, false, "Notifications not available on macOS versions prior to 10.14");
}
}
- (void) CheckNotificationAuthorization :(int) channelID {
if (@available(macOS 10.14, *)) {
if (![self EnsureDelegateInitialized]) {
NSString *errorMsg = @"Notification delegate has been lost. Reinitialize the notification service.";
captureResult(channelID, false, [errorMsg UTF8String]);
return;
}
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
[center getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings *settings) {
BOOL isAuthorized = (settings.authorizationStatus == UNAuthorizationStatusAuthorized);
captureResult(channelID, isAuthorized, NULL);
}];
} else {
captureResult(channelID, false, "Notifications not available on macOS versions prior to 10.14");
}
}
- (UNMutableNotificationContent *)createNotificationContent:(const char *)title subtitle:(const char *)subtitle body:(const char *)body dataJSON:(const char *)dataJSON error:(NSError **)contentError API_AVAILABLE(macos(10.14)) {
if (title == NULL) title = "";
if (body == NULL) body = "";
NSString *nsTitle = [NSString stringWithUTF8String:title];
NSString *nsSubtitle = subtitle ? [NSString stringWithUTF8String:subtitle] : @"";
NSString *nsBody = [NSString stringWithUTF8String:body];
UNMutableNotificationContent *content = [[[UNMutableNotificationContent alloc] init] autorelease];
content.title = nsTitle;
if (![nsSubtitle isEqualToString:@""]) {
content.subtitle = nsSubtitle;
}
content.body = nsBody;
content.sound = [UNNotificationSound defaultSound];
// Parse JSON data if provided
if (dataJSON) {
NSString *dataJsonStr = [NSString stringWithUTF8String:dataJSON];
NSData *jsonData = [dataJsonStr dataUsingEncoding:NSUTF8StringEncoding];
NSError *error = nil;
NSDictionary *parsedData = [NSJSONSerialization JSONObjectWithData:jsonData options:0 error:&error];
if (!error && parsedData) {
content.userInfo = parsedData;
} else if (error) {
if (contentError) *contentError = error;
}
}
return content;
}
- (void) sendNotificationWithRequest:(UNNotificationRequest *)request channelID:(int)channelID API_AVAILABLE(macos(10.14)) {
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
[center addNotificationRequest:request withCompletionHandler:^(NSError * _Nullable error) {
if (error) {
NSString *errorMsg = [NSString stringWithFormat:@"Error: %@", [error localizedDescription]];
captureResult(channelID, false, [errorMsg UTF8String]);
} else {
captureResult(channelID, true, NULL);
}
}];
}
- (void) SendNotification:(int)channelID :(const char *)identifier :(const char *)title :(const char *)subtitle :(const char *)body :(const char *)dataJSON API_AVAILABLE(macos(10.14)) {
if (![self EnsureDelegateInitialized]) {
NSString *errorMsg = @"Notification delegate has been lost. Reinitialize the notification service.";
captureResult(channelID, false, [errorMsg UTF8String]);
return;
}
NSString *nsIdentifier = [NSString stringWithUTF8String:identifier];
NSError *contentError = nil;
UNMutableNotificationContent *content = [self createNotificationContent:title subtitle:subtitle body:body dataJSON:dataJSON error:&contentError];
if (contentError) {
NSString *errorMsg = [NSString stringWithFormat:@"Error: %@", [contentError localizedDescription]];
captureResult(channelID, false, [errorMsg UTF8String]);
return;
}
UNTimeIntervalNotificationTrigger *trigger = nil;
UNNotificationRequest *request = [UNNotificationRequest requestWithIdentifier:nsIdentifier content:content trigger:trigger];
[self sendNotificationWithRequest:request channelID:channelID];
}
- (void) SendNotificationWithActions:(int)channelID :(const char *)identifier :(const char *)title :(const char *)subtitle :(const char *)body :(const char *)categoryId :(const char *)dataJSON API_AVAILABLE(macos(10.14)) {
if (![self EnsureDelegateInitialized]) {
NSString *errorMsg = @"Notification delegate has been lost. Reinitialize the notification service.";
captureResult(channelID, false, [errorMsg UTF8String]);
return;
}
NSString *nsIdentifier = [NSString stringWithUTF8String:identifier];
NSString *nsCategoryId = [NSString stringWithUTF8String:categoryId];
NSError *contentError = nil;
UNMutableNotificationContent *content = [self createNotificationContent:title subtitle:subtitle body:body dataJSON:dataJSON error:&contentError];
if (contentError) {
NSString *errorMsg = [NSString stringWithFormat:@"Error: %@", [contentError localizedDescription]];
captureResult(channelID, false, [errorMsg UTF8String]);
return;
}
content.categoryIdentifier = nsCategoryId;
UNTimeIntervalNotificationTrigger *trigger = nil;
UNNotificationRequest *request = [UNNotificationRequest requestWithIdentifier:nsIdentifier content:content trigger:trigger];
[self sendNotificationWithRequest:request channelID:channelID];
}
- (void) RegisterNotificationCategory:(int)channelID :(const char *)categoryId :(const char *)actionsJSON :(bool)hasReplyField :(const char *)replyPlaceholder :(const char *)replyButtonTitle API_AVAILABLE(macos(10.14)) {
if (![self EnsureDelegateInitialized]) {
NSString *errorMsg = @"Notification delegate has been lost. Reinitialize the notification service.";
captureResult(channelID, false, [errorMsg UTF8String]);
return;
}
NSString *nsCategoryId = [NSString stringWithUTF8String:categoryId];
NSString *actionsJsonStr = actionsJSON ? [NSString stringWithUTF8String:actionsJSON] : @"[]";
NSData *jsonData = [actionsJsonStr dataUsingEncoding:NSUTF8StringEncoding];
NSError *error = nil;
NSArray *actionsArray = [NSJSONSerialization JSONObjectWithData:jsonData options:0 error:&error];
if (error) {
NSString *errorMsg = [NSString stringWithFormat:@"Error: %@", [error localizedDescription]];
captureResult(channelID, false, [errorMsg UTF8String]);
return;
}
NSMutableArray *actions = [NSMutableArray array];
for (NSDictionary *actionDict in actionsArray) {
NSString *actionId = actionDict[@"id"];
NSString *actionTitle = actionDict[@"title"];
BOOL destructive = [actionDict[@"destructive"] boolValue];
if (actionId && actionTitle) {
UNNotificationActionOptions options = UNNotificationActionOptionNone;
if (destructive) options |= UNNotificationActionOptionDestructive;
UNNotificationAction *action = [UNNotificationAction actionWithIdentifier:actionId
title:actionTitle
options:options];
[actions addObject:action];
}
}
if (hasReplyField) {
// Defensive NULL checks: if hasReplyField is true, both strings must be non-NULL
if (!replyPlaceholder || !replyButtonTitle) {
NSString *errorMsg = @"hasReplyField is true but replyPlaceholder or replyButtonTitle is NULL";
captureResult(channelID, false, [errorMsg UTF8String]);
return;
}
NSString *placeholder = [NSString stringWithUTF8String:replyPlaceholder];
NSString *buttonTitle = [NSString stringWithUTF8String:replyButtonTitle];
UNTextInputNotificationAction *textAction =
[UNTextInputNotificationAction actionWithIdentifier:@"TEXT_REPLY"
title:buttonTitle
options:UNNotificationActionOptionNone
textInputButtonTitle:buttonTitle
textInputPlaceholder:placeholder];
[actions addObject:textAction];
}
UNNotificationCategory *newCategory = [UNNotificationCategory categoryWithIdentifier:nsCategoryId
actions:actions
intentIdentifiers:@[]
options:UNNotificationCategoryOptionNone];
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
[center getNotificationCategoriesWithCompletionHandler:^(NSSet<UNNotificationCategory *> *categories) {
NSMutableSet *updatedCategories = [NSMutableSet setWithSet:categories];
// Remove existing category with same identifier if found
UNNotificationCategory *existingCategory = nil;
for (UNNotificationCategory *category in updatedCategories) {
if ([category.identifier isEqualToString:nsCategoryId]) {
existingCategory = category;
break;
}
}
if (existingCategory) {
[updatedCategories removeObject:existingCategory];
}
// Add the new category
[updatedCategories addObject:newCategory];
[center setNotificationCategories:updatedCategories];
captureResult(channelID, true, NULL);
}];
}
- (void) RemoveNotificationCategory:(int)channelID :(const char *)categoryId API_AVAILABLE(macos(10.14)) {
NSString *nsCategoryId = [NSString stringWithUTF8String:categoryId];
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
[center getNotificationCategoriesWithCompletionHandler:^(NSSet<UNNotificationCategory *> *categories) {
NSMutableSet *updatedCategories = [NSMutableSet setWithSet:categories];
// Find and remove the matching category
UNNotificationCategory *categoryToRemove = nil;
for (UNNotificationCategory *category in updatedCategories) {
if ([category.identifier isEqualToString:nsCategoryId]) {
categoryToRemove = category;
break;
}
}
if (categoryToRemove) {
[updatedCategories removeObject:categoryToRemove];
[center setNotificationCategories:updatedCategories];
captureResult(channelID, true, NULL);
} else {
NSString *errorMsg = [NSString stringWithFormat:@"Category '%@' not found", nsCategoryId];
captureResult(channelID, false, [errorMsg UTF8String]);
}
}];
}
- (void) RemoveAllPendingNotifications API_AVAILABLE(macos(10.14)) {
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
[center removeAllPendingNotificationRequests];
}
- (void) RemovePendingNotification:(const char *)identifier API_AVAILABLE(macos(10.14)) {
NSString *nsIdentifier = [NSString stringWithUTF8String:identifier];
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
[center removePendingNotificationRequestsWithIdentifiers:@[nsIdentifier]];
}
- (void) RemoveAllDeliveredNotifications API_AVAILABLE(macos(10.14)) {
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
[center removeAllDeliveredNotifications];
}
- (void) RemoveDeliveredNotification:(const char *)identifier API_AVAILABLE(macos(10.14)) {
NSString *nsIdentifier = [NSString stringWithUTF8String:identifier];
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
[center removeDeliveredNotificationsWithIdentifiers:@[nsIdentifier]];
}
- (void) SetAbout :(NSString*)title :(NSString*)description :(void*)imagedata :(int)datalen {
self.aboutTitle = title;
self.aboutDescription = description;
@ -731,7 +1091,7 @@ typedef void (^schemeTaskCaller)(id<WKURLSchemeTask>);
self.aboutImage = [[NSImage alloc] initWithData:imageData];
}
-(void) About {
- (void) About {
WailsAlert *alert = [WailsAlert new];
[alert setAlertStyle:NSAlertStyleInformational];

View file

@ -0,0 +1,465 @@
//go:build darwin
// +build darwin
package darwin
/*
#cgo CFLAGS:-x objective-c
#cgo LDFLAGS: -framework Foundation -framework Cocoa
#if __MAC_OS_X_VERSION_MAX_ALLOWED >= 110000
#cgo LDFLAGS: -framework UserNotifications
#endif
#import "Application.h"
#import "WailsContext.h"
*/
import "C"
import (
"context"
"encoding/json"
"fmt"
"os"
"sync"
"time"
"unsafe"
"github.com/wailsapp/wails/v2/internal/frontend"
)
// Package-scoped variable only accessible within this file
var (
currentFrontend *Frontend
frontendMutex sync.RWMutex
// Notification channels
channels map[int]chan notificationChannel
channelsLock sync.Mutex
nextChannelID int
notificationResultCallback func(result frontend.NotificationResult)
callbackLock sync.RWMutex
)
const DefaultActionIdentifier = "DEFAULT_ACTION"
const AppleDefaultActionIdentifier = "com.apple.UNNotificationDefaultActionIdentifier"
// setCurrentFrontend sets the current frontend instance
// This is called when RequestNotificationAuthorization or CheckNotificationAuthorization is called
func setCurrentFrontend(f *Frontend) {
frontendMutex.Lock()
defer frontendMutex.Unlock()
currentFrontend = f
}
// getCurrentFrontend gets the current frontend instance
func getCurrentFrontend() *Frontend {
frontendMutex.RLock()
defer frontendMutex.RUnlock()
return currentFrontend
}
type notificationChannel struct {
Success bool
Error error
}
func (f *Frontend) InitializeNotifications() error {
if !f.IsNotificationAvailable() {
return fmt.Errorf("notifications are not available on this system")
}
if !f.checkBundleIdentifier() {
return fmt.Errorf("notifications require a valid bundle identifier")
}
if !bool(C.EnsureDelegateInitialized(f.mainWindow.context)) {
return fmt.Errorf("failed to initialize notification center delegate")
}
channels = make(map[int]chan notificationChannel)
nextChannelID = 0
setCurrentFrontend(f)
return nil
}
// CleanupNotifications is a macOS stub that does nothing.
// (Linux-specific cleanup)
func (f *Frontend) CleanupNotifications() {
// No cleanup needed on macOS
}
func (f *Frontend) IsNotificationAvailable() bool {
return bool(C.IsNotificationAvailable(f.mainWindow.context))
}
func (f *Frontend) checkBundleIdentifier() bool {
return bool(C.CheckBundleIdentifier(f.mainWindow.context))
}
func (f *Frontend) RequestNotificationAuthorization() (bool, error) {
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel()
id, resultCh := f.registerChannel()
C.RequestNotificationAuthorization(f.mainWindow.context, C.int(id))
select {
case result := <-resultCh:
close(resultCh)
return result.Success, result.Error
case <-ctx.Done():
f.cleanupChannel(id)
return false, fmt.Errorf("notification authorization timed out after 3 minutes: %w", ctx.Err())
}
}
func (f *Frontend) CheckNotificationAuthorization() (bool, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
id, resultCh := f.registerChannel()
C.CheckNotificationAuthorization(f.mainWindow.context, C.int(id))
select {
case result := <-resultCh:
close(resultCh)
return result.Success, result.Error
case <-ctx.Done():
f.cleanupChannel(id)
return false, fmt.Errorf("notification authorization timed out after 15s: %w", ctx.Err())
}
}
// SendNotification sends a basic notification with a unique identifier, title, subtitle, and body.
func (f *Frontend) SendNotification(options frontend.NotificationOptions) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cIdentifier := C.CString(options.ID)
cTitle := C.CString(options.Title)
cSubtitle := C.CString(options.Subtitle)
cBody := C.CString(options.Body)
defer C.free(unsafe.Pointer(cIdentifier))
defer C.free(unsafe.Pointer(cTitle))
defer C.free(unsafe.Pointer(cSubtitle))
defer C.free(unsafe.Pointer(cBody))
var cDataJSON *C.char
if options.Data != nil {
jsonData, err := json.Marshal(options.Data)
if err != nil {
return fmt.Errorf("failed to marshal notification data: %w", err)
}
cDataJSON = C.CString(string(jsonData))
defer C.free(unsafe.Pointer(cDataJSON))
}
id, resultCh := f.registerChannel()
C.SendNotification(f.mainWindow.context, C.int(id), cIdentifier, cTitle, cSubtitle, cBody, cDataJSON)
select {
case result := <-resultCh:
close(resultCh)
if !result.Success {
if result.Error != nil {
return result.Error
}
return fmt.Errorf("sending notification failed")
}
return nil
case <-ctx.Done():
f.cleanupChannel(id)
return fmt.Errorf("sending notification timed out: %w", ctx.Err())
}
}
// SendNotificationWithActions sends a notification with additional actions and inputs.
// A NotificationCategory must be registered with RegisterNotificationCategory first. The `CategoryID` must match the registered category.
// If a NotificationCategory is not registered a basic notification will be sent.
func (f *Frontend) SendNotificationWithActions(options frontend.NotificationOptions) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cIdentifier := C.CString(options.ID)
cTitle := C.CString(options.Title)
cSubtitle := C.CString(options.Subtitle)
cBody := C.CString(options.Body)
cCategoryID := C.CString(options.CategoryID)
defer C.free(unsafe.Pointer(cIdentifier))
defer C.free(unsafe.Pointer(cTitle))
defer C.free(unsafe.Pointer(cSubtitle))
defer C.free(unsafe.Pointer(cBody))
defer C.free(unsafe.Pointer(cCategoryID))
var cDataJSON *C.char
if options.Data != nil {
jsonData, err := json.Marshal(options.Data)
if err != nil {
return fmt.Errorf("failed to marshal notification data: %w", err)
}
cDataJSON = C.CString(string(jsonData))
defer C.free(unsafe.Pointer(cDataJSON))
}
id, resultCh := f.registerChannel()
C.SendNotificationWithActions(f.mainWindow.context, C.int(id), cIdentifier, cTitle, cSubtitle, cBody, cCategoryID, cDataJSON)
select {
case result := <-resultCh:
close(resultCh)
if !result.Success {
if result.Error != nil {
return result.Error
}
return fmt.Errorf("sending notification failed")
}
return nil
case <-ctx.Done():
f.cleanupChannel(id)
return fmt.Errorf("sending notification timed out: %w", ctx.Err())
}
}
// 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 (f *Frontend) RegisterNotificationCategory(category frontend.NotificationCategory) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cCategoryID := C.CString(category.ID)
defer C.free(unsafe.Pointer(cCategoryID))
actionsJSON, err := json.Marshal(category.Actions)
if err != nil {
return fmt.Errorf("failed to marshal notification category: %w", err)
}
cActionsJSON := C.CString(string(actionsJSON))
defer C.free(unsafe.Pointer(cActionsJSON))
var cReplyPlaceholder, cReplyButtonTitle *C.char
if category.HasReplyField {
cReplyPlaceholder = C.CString(category.ReplyPlaceholder)
cReplyButtonTitle = C.CString(category.ReplyButtonTitle)
defer C.free(unsafe.Pointer(cReplyPlaceholder))
defer C.free(unsafe.Pointer(cReplyButtonTitle))
}
id, resultCh := f.registerChannel()
C.RegisterNotificationCategory(f.mainWindow.context, C.int(id), cCategoryID, cActionsJSON, C.bool(category.HasReplyField),
cReplyPlaceholder, cReplyButtonTitle)
select {
case result := <-resultCh:
close(resultCh)
if !result.Success {
if result.Error != nil {
return result.Error
}
return fmt.Errorf("category registration failed")
}
return nil
case <-ctx.Done():
f.cleanupChannel(id)
return fmt.Errorf("category registration timed out: %w", ctx.Err())
}
}
// RemoveNotificationCategory remove a previously registered NotificationCategory.
func (f *Frontend) RemoveNotificationCategory(categoryId string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cCategoryID := C.CString(categoryId)
defer C.free(unsafe.Pointer(cCategoryID))
id, resultCh := f.registerChannel()
C.RemoveNotificationCategory(f.mainWindow.context, C.int(id), cCategoryID)
select {
case result := <-resultCh:
close(resultCh)
if !result.Success {
if result.Error != nil {
return result.Error
}
return fmt.Errorf("category removal failed")
}
return nil
case <-ctx.Done():
f.cleanupChannel(id)
return fmt.Errorf("category removal timed out: %w", ctx.Err())
}
}
// RemoveAllPendingNotifications removes all pending notifications.
func (f *Frontend) RemoveAllPendingNotifications() error {
C.RemoveAllPendingNotifications(f.mainWindow.context)
return nil
}
// RemovePendingNotification removes a pending notification matching the unique identifier.
func (f *Frontend) RemovePendingNotification(identifier string) error {
cIdentifier := C.CString(identifier)
defer C.free(unsafe.Pointer(cIdentifier))
C.RemovePendingNotification(f.mainWindow.context, cIdentifier)
return nil
}
// RemoveAllDeliveredNotifications removes all delivered notifications.
func (f *Frontend) RemoveAllDeliveredNotifications() error {
C.RemoveAllDeliveredNotifications(f.mainWindow.context)
return nil
}
// RemoveDeliveredNotification removes a delivered notification matching the unique identifier.
func (f *Frontend) RemoveDeliveredNotification(identifier string) error {
cIdentifier := C.CString(identifier)
defer C.free(unsafe.Pointer(cIdentifier))
C.RemoveDeliveredNotification(f.mainWindow.context, cIdentifier)
return nil
}
// RemoveNotification is a macOS stub that always returns nil.
// Use one of the following instead:
// RemoveAllPendingNotifications
// RemovePendingNotification
// RemoveAllDeliveredNotifications
// RemoveDeliveredNotification
// (Linux-specific)
func (f *Frontend) RemoveNotification(identifier string) error {
return nil
}
func (f *Frontend) OnNotificationResponse(callback func(result frontend.NotificationResult)) {
callbackLock.Lock()
notificationResultCallback = callback
callbackLock.Unlock()
}
//export captureResult
func captureResult(channelID C.int, success C.bool, errorMsg *C.char) {
f := getCurrentFrontend()
if f == nil {
return
}
resultCh, exists := f.GetChannel(int(channelID))
if !exists {
return
}
var err error
if errorMsg != nil {
err = fmt.Errorf("%s", C.GoString(errorMsg))
}
resultCh <- notificationChannel{
Success: bool(success),
Error: err,
}
}
//export didReceiveNotificationResponse
func didReceiveNotificationResponse(jsonPayload *C.char, err *C.char) {
result := frontend.NotificationResult{}
if err != nil {
errMsg := C.GoString(err)
result.Error = fmt.Errorf("notification response error: %s", errMsg)
handleNotificationResult(result)
return
}
if jsonPayload == nil {
result.Error = fmt.Errorf("received nil JSON payload in notification response")
handleNotificationResult(result)
return
}
payload := C.GoString(jsonPayload)
var response frontend.NotificationResponse
if err := json.Unmarshal([]byte(payload), &response); err != nil {
result.Error = fmt.Errorf("failed to unmarshal notification response: %w", err)
handleNotificationResult(result)
return
}
if response.ActionIdentifier == AppleDefaultActionIdentifier {
response.ActionIdentifier = DefaultActionIdentifier
}
result.Response = response
handleNotificationResult(result)
}
func handleNotificationResult(result frontend.NotificationResult) {
callbackLock.Lock()
callback := notificationResultCallback
callbackLock.Unlock()
if callback != nil {
go func() {
defer func() {
if r := recover(); r != nil {
// Log panic but don't crash the app
fmt.Fprintf(os.Stderr, "panic in notification callback: %v\n", r)
}
}()
callback(result)
}()
}
}
// Helper methods
func (f *Frontend) registerChannel() (int, chan notificationChannel) {
channelsLock.Lock()
defer channelsLock.Unlock()
// Initialize channels map if it's nil
if channels == nil {
channels = make(map[int]chan notificationChannel)
nextChannelID = 0
}
id := nextChannelID
nextChannelID++
resultCh := make(chan notificationChannel, 1)
channels[id] = resultCh
return id, resultCh
}
func (f *Frontend) GetChannel(id int) (chan notificationChannel, bool) {
channelsLock.Lock()
defer channelsLock.Unlock()
if channels == nil {
return nil, false
}
ch, exists := channels[id]
if exists {
delete(channels, id)
}
return ch, exists
}
func (f *Frontend) cleanupChannel(id int) {
channelsLock.Lock()
defer channelsLock.Unlock()
if channels == nil {
return
}
if ch, exists := channels[id]; exists {
delete(channels, id)
close(ch)
}
}

View file

@ -0,0 +1,594 @@
//go:build linux
// +build linux
package linux
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"github.com/godbus/dbus/v5"
"github.com/wailsapp/wails/v2/internal/frontend"
)
var (
conn *dbus.Conn
categories map[string]frontend.NotificationCategory = make(map[string]frontend.NotificationCategory)
categoriesLock sync.RWMutex
notifications map[uint32]*notificationData = make(map[uint32]*notificationData)
notificationsLock sync.RWMutex
notificationResultCallback func(result frontend.NotificationResult)
callbackLock sync.RWMutex
appName string
cancel context.CancelFunc
)
type notificationData struct {
ID string
Title string
Subtitle string
Body string
CategoryID string
Data map[string]interface{}
DBusID uint32
ActionMap map[string]string
}
const (
dbusNotificationInterface = "org.freedesktop.Notifications"
dbusNotificationPath = "/org/freedesktop/Notifications"
DefaultActionIdentifier = "DEFAULT_ACTION"
)
// Creates a new Notifications Service.
func (f *Frontend) InitializeNotifications() error {
// Clean up any previous initialization
f.CleanupNotifications()
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to get executable: %w", err)
}
appName = filepath.Base(exe)
_conn, err := dbus.ConnectSessionBus()
if err != nil {
return fmt.Errorf("failed to connect to session bus: %w", err)
}
conn = _conn
if err := f.loadCategories(); err != nil {
f.logger.Warning("Failed to load notification categories: %v", err)
}
var signalCtx context.Context
signalCtx, cancel = context.WithCancel(context.Background())
if err := f.setupSignalHandling(signalCtx); err != nil {
return fmt.Errorf("failed to set up notification signal handling: %w", err)
}
return nil
}
// CleanupNotifications cleans up notification resources
func (f *Frontend) CleanupNotifications() {
if cancel != nil {
cancel()
cancel = nil
}
if conn != nil {
conn.Close()
conn = nil
}
}
func (f *Frontend) IsNotificationAvailable() bool {
return true
}
// RequestNotificationAuthorization is a Linux stub that always returns true, nil.
// (authorization is macOS-specific)
func (f *Frontend) RequestNotificationAuthorization() (bool, error) {
return true, nil
}
// CheckNotificationAuthorization is a Linux stub that always returns true.
// (authorization is macOS-specific)
func (f *Frontend) CheckNotificationAuthorization() (bool, error) {
return true, nil
}
// SendNotification sends a basic notification with a unique identifier, title, subtitle, and body.
func (f *Frontend) SendNotification(options frontend.NotificationOptions) error {
if conn == nil {
return fmt.Errorf("notifications not initialized")
}
hints := map[string]dbus.Variant{}
body := options.Body
if options.Subtitle != "" {
body = options.Subtitle + "\n" + body
}
defaultActionID := "default"
actions := []string{defaultActionID, "Default"}
actionMap := map[string]string{
defaultActionID: DefaultActionIdentifier,
}
hints["x-notification-id"] = dbus.MakeVariant(options.ID)
if options.Data != nil {
userData, err := json.Marshal(options.Data)
if err == nil {
hints["x-user-data"] = dbus.MakeVariant(string(userData))
}
}
// Call the Notify method on the D-Bus interface
obj := conn.Object(dbusNotificationInterface, dbusNotificationPath)
call := obj.Call(
dbusNotificationInterface+".Notify",
0,
appName,
uint32(0),
"", // Icon
options.Title,
body,
actions,
hints,
int32(-1),
)
if call.Err != nil {
return fmt.Errorf("failed to send notification: %w", call.Err)
}
var dbusID uint32
if err := call.Store(&dbusID); err != nil {
return fmt.Errorf("failed to store notification ID: %w", err)
}
notification := &notificationData{
ID: options.ID,
Title: options.Title,
Subtitle: options.Subtitle,
Body: options.Body,
Data: options.Data,
DBusID: dbusID,
ActionMap: actionMap,
}
notificationsLock.Lock()
notifications[dbusID] = notification
notificationsLock.Unlock()
return nil
}
// SendNotificationWithActions sends a notification with additional actions.
func (f *Frontend) SendNotificationWithActions(options frontend.NotificationOptions) error {
if conn == nil {
return fmt.Errorf("notifications not initialized")
}
categoriesLock.RLock()
category, exists := categories[options.CategoryID]
categoriesLock.RUnlock()
if options.CategoryID == "" || !exists {
// Fall back to basic notification
return f.SendNotification(options)
}
body := options.Body
if options.Subtitle != "" {
body = options.Subtitle + "\n" + body
}
var actions []string
actionMap := make(map[string]string)
defaultActionID := "default"
actions = append(actions, defaultActionID, "Default")
actionMap[defaultActionID] = DefaultActionIdentifier
for _, action := range category.Actions {
actions = append(actions, action.ID, action.Title)
actionMap[action.ID] = action.ID
}
hints := map[string]dbus.Variant{}
hints["x-notification-id"] = dbus.MakeVariant(options.ID)
hints["x-category-id"] = dbus.MakeVariant(options.CategoryID)
if options.Data != nil {
userData, err := json.Marshal(options.Data)
if err == nil {
hints["x-user-data"] = dbus.MakeVariant(string(userData))
}
}
obj := conn.Object(dbusNotificationInterface, dbusNotificationPath)
call := obj.Call(
dbusNotificationInterface+".Notify",
0,
appName,
uint32(0),
"", // Icon
options.Title,
body,
actions,
hints,
int32(-1),
)
if call.Err != nil {
return fmt.Errorf("failed to send notification: %w", call.Err)
}
var dbusID uint32
if err := call.Store(&dbusID); err != nil {
return fmt.Errorf("failed to store notification ID: %w", err)
}
notification := &notificationData{
ID: options.ID,
Title: options.Title,
Subtitle: options.Subtitle,
Body: options.Body,
CategoryID: options.CategoryID,
Data: options.Data,
DBusID: dbusID,
ActionMap: actionMap,
}
notificationsLock.Lock()
notifications[dbusID] = notification
notificationsLock.Unlock()
return nil
}
// RegisterNotificationCategory registers a new NotificationCategory to be used with SendNotificationWithActions.
func (f *Frontend) RegisterNotificationCategory(category frontend.NotificationCategory) error {
categoriesLock.Lock()
categories[category.ID] = category
categoriesLock.Unlock()
if err := f.saveCategories(); err != nil {
f.logger.Warning("Failed to save notification categories: %v", err)
}
return nil
}
// RemoveNotificationCategory removes a previously registered NotificationCategory.
func (f *Frontend) RemoveNotificationCategory(categoryId string) error {
categoriesLock.Lock()
delete(categories, categoryId)
categoriesLock.Unlock()
if err := f.saveCategories(); err != nil {
f.logger.Warning("Failed to save notification categories: %v", err)
}
return nil
}
// RemoveAllPendingNotifications attempts to remove all active notifications.
func (f *Frontend) RemoveAllPendingNotifications() error {
notificationsLock.Lock()
dbusIDs := make([]uint32, 0, len(notifications))
for id := range notifications {
dbusIDs = append(dbusIDs, id)
}
notificationsLock.Unlock()
for _, id := range dbusIDs {
f.closeNotification(id)
}
return nil
}
// RemovePendingNotification removes a pending notification.
func (f *Frontend) RemovePendingNotification(identifier string) error {
var dbusID uint32
found := false
notificationsLock.Lock()
for id, notif := range notifications {
if notif.ID == identifier {
dbusID = id
found = true
break
}
}
notificationsLock.Unlock()
if !found {
return nil
}
return f.closeNotification(dbusID)
}
// RemoveAllDeliveredNotifications functionally equivalent to RemoveAllPendingNotification on Linux.
func (f *Frontend) RemoveAllDeliveredNotifications() error {
return f.RemoveAllPendingNotifications()
}
// RemoveDeliveredNotification functionally equivalent RemovePendingNotification on Linux.
func (f *Frontend) RemoveDeliveredNotification(identifier string) error {
return f.RemovePendingNotification(identifier)
}
// RemoveNotification removes a notification by identifier.
func (f *Frontend) RemoveNotification(identifier string) error {
return f.RemovePendingNotification(identifier)
}
func (f *Frontend) OnNotificationResponse(callback func(result frontend.NotificationResult)) {
callbackLock.Lock()
defer callbackLock.Unlock()
notificationResultCallback = callback
}
// Helper method to close a notification.
func (f *Frontend) closeNotification(id uint32) error {
if conn == nil {
return fmt.Errorf("notifications not initialized")
}
obj := conn.Object(dbusNotificationInterface, dbusNotificationPath)
call := obj.Call(dbusNotificationInterface+".CloseNotification", 0, id)
if call.Err != nil {
return fmt.Errorf("failed to close notification: %w", call.Err)
}
return nil
}
func (f *Frontend) getConfigDir() (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("failed to get user config directory: %w", err)
}
appConfigDir := filepath.Join(configDir, appName)
if err := os.MkdirAll(appConfigDir, 0755); err != nil {
return "", fmt.Errorf("failed to create app config directory: %w", err)
}
return appConfigDir, nil
}
// Save notification categories.
func (f *Frontend) saveCategories() error {
configDir, err := f.getConfigDir()
if err != nil {
return err
}
categoriesFile := filepath.Join(configDir, "notification-categories.json")
categoriesLock.RLock()
categoriesData, err := json.MarshalIndent(categories, "", " ")
categoriesLock.RUnlock()
if err != nil {
return fmt.Errorf("failed to marshal notification categories: %w", err)
}
if err := os.WriteFile(categoriesFile, categoriesData, 0644); err != nil {
return fmt.Errorf("failed to write notification categories to disk: %w", err)
}
return nil
}
// Load notification categories.
func (f *Frontend) loadCategories() error {
configDir, err := f.getConfigDir()
if err != nil {
return err
}
categoriesFile := filepath.Join(configDir, "notification-categories.json")
if _, err := os.Stat(categoriesFile); os.IsNotExist(err) {
return nil
}
categoriesData, err := os.ReadFile(categoriesFile)
if err != nil {
return fmt.Errorf("failed to read notification categories from disk: %w", err)
}
_categories := make(map[string]frontend.NotificationCategory)
if err := json.Unmarshal(categoriesData, &_categories); err != nil {
return fmt.Errorf("failed to unmarshal notification categories: %w", err)
}
categoriesLock.Lock()
categories = _categories
categoriesLock.Unlock()
return nil
}
// Setup signal handling for notification actions.
func (f *Frontend) setupSignalHandling(ctx context.Context) error {
if err := conn.AddMatchSignal(
dbus.WithMatchInterface(dbusNotificationInterface),
dbus.WithMatchMember("ActionInvoked"),
); err != nil {
return err
}
if err := conn.AddMatchSignal(
dbus.WithMatchInterface(dbusNotificationInterface),
dbus.WithMatchMember("NotificationClosed"),
); err != nil {
return err
}
c := make(chan *dbus.Signal, 10)
conn.Signal(c)
go f.handleSignals(ctx, c)
return nil
}
// Handle incoming D-Bus signals.
func (f *Frontend) handleSignals(ctx context.Context, c chan *dbus.Signal) {
for {
select {
case <-ctx.Done():
return
case signal, ok := <-c:
if !ok {
return
}
switch signal.Name {
case dbusNotificationInterface + ".ActionInvoked":
f.handleActionInvoked(signal)
case dbusNotificationInterface + ".NotificationClosed":
f.handleNotificationClosed(signal)
}
}
}
}
// Handle ActionInvoked signal.
func (f *Frontend) handleActionInvoked(signal *dbus.Signal) {
if len(signal.Body) < 2 {
return
}
dbusID, ok := signal.Body[0].(uint32)
if !ok {
return
}
actionID, ok := signal.Body[1].(string)
if !ok {
return
}
notificationsLock.Lock()
notification, exists := notifications[dbusID]
if exists {
delete(notifications, dbusID)
}
notificationsLock.Unlock()
if !exists {
return
}
appActionID, ok := notification.ActionMap[actionID]
if !ok {
appActionID = actionID
}
response := frontend.NotificationResponse{
ID: notification.ID,
ActionIdentifier: appActionID,
Title: notification.Title,
Subtitle: notification.Subtitle,
Body: notification.Body,
CategoryID: notification.CategoryID,
UserInfo: notification.Data,
}
result := frontend.NotificationResult{
Response: response,
}
handleNotificationResult(result)
}
func handleNotificationResult(result frontend.NotificationResult) {
callbackLock.Lock()
callback := notificationResultCallback
callbackLock.Unlock()
if callback != nil {
go func() {
defer func() {
if r := recover(); r != nil {
// Log panic but don't crash the app
fmt.Fprintf(os.Stderr, "panic in notification callback: %v\n", r)
}
}()
callback(result)
}()
}
}
// Handle NotificationClosed signal.
// Reason codes:
// 1 - expired timeout
// 2 - dismissed by user (click on X)
// 3 - closed by CloseNotification call
// 4 - undefined/reserved
func (f *Frontend) handleNotificationClosed(signal *dbus.Signal) {
if len(signal.Body) < 2 {
return
}
dbusID, ok := signal.Body[0].(uint32)
if !ok {
return
}
reason, ok := signal.Body[1].(uint32)
if !ok {
reason = 0 // Unknown reason
}
notificationsLock.Lock()
notification, exists := notifications[dbusID]
if exists {
delete(notifications, dbusID)
}
notificationsLock.Unlock()
if !exists {
return
}
if reason == 2 {
response := frontend.NotificationResponse{
ID: notification.ID,
ActionIdentifier: DefaultActionIdentifier,
Title: notification.Title,
Subtitle: notification.Subtitle,
Body: notification.Body,
CategoryID: notification.CategoryID,
UserInfo: notification.Data,
}
result := frontend.NotificationResult{
Response: response,
}
handleNotificationResult(result)
}
}

View file

@ -0,0 +1,489 @@
//go:build windows
// +build windows
package windows
import (
"encoding/base64"
"encoding/json"
"log"
"sync"
wintoast "git.sr.ht/~jackmordaunt/go-toast/v2/wintoast"
"github.com/google/uuid"
"github.com/wailsapp/wails/v2/internal/frontend"
"github.com/wailsapp/wails/v2/internal/frontend/desktop/windows/winc"
"github.com/wailsapp/wails/v2/internal/frontend/desktop/windows/winc/w32"
"fmt"
"os"
"path/filepath"
_ "unsafe" // for go:linkname
"git.sr.ht/~jackmordaunt/go-toast/v2"
"golang.org/x/sys/windows/registry"
)
var (
categories map[string]frontend.NotificationCategory
categoriesLock sync.RWMutex
appName string
appGUID string
iconPath string = ""
exePath string
iconOnce sync.Once
iconErr error
notificationResultCallback func(result frontend.NotificationResult)
callbackLock sync.RWMutex
)
const DefaultActionIdentifier = "DEFAULT_ACTION"
const (
ToastRegistryPath = `Software\Classes\AppUserModelId\`
ToastRegistryGuidKey = "CustomActivator"
NotificationCategoriesRegistryPath = `SOFTWARE\%s\NotificationCategories`
NotificationCategoriesRegistryKey = "Categories"
)
// NotificationPayload combines the action ID and user data into a single structure
type NotificationPayload struct {
Action string `json:"action"`
Options frontend.NotificationOptions `json:"payload,omitempty"`
}
func (f *Frontend) InitializeNotifications() error {
categoriesLock.Lock()
defer categoriesLock.Unlock()
categories = make(map[string]frontend.NotificationCategory)
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to get executable: %w", err)
}
exePath = exe
appName = filepath.Base(exePath)
appGUID, err = getGUID()
if err != nil {
return err
}
iconPath = filepath.Join(os.TempDir(), appName+appGUID+".png")
// Create the registry key for the toast activator
key, _, err := registry.CreateKey(registry.CURRENT_USER,
`Software\Classes\CLSID\`+appGUID+`\LocalServer32`, registry.ALL_ACCESS)
if err != nil {
return fmt.Errorf("failed to create CLSID key: %w", err)
}
defer key.Close()
if err := key.SetStringValue("", fmt.Sprintf("\"%s\" %%1", exePath)); err != nil {
return fmt.Errorf("failed to set CLSID server path: %w", err)
}
toast.SetAppData(toast.AppData{
AppID: appName,
GUID: appGUID,
IconPath: iconPath,
ActivationExe: exePath,
})
toast.SetActivationCallback(func(args string, data []toast.UserData) {
result := frontend.NotificationResult{}
actionIdentifier, options, err := parseNotificationResponse(args)
if err != nil {
result.Error = err
} else {
// Subtitle is retained but was not shown with the notification
response := frontend.NotificationResponse{
ID: options.ID,
ActionIdentifier: actionIdentifier,
Title: options.Title,
Subtitle: options.Subtitle,
Body: options.Body,
CategoryID: options.CategoryID,
UserInfo: options.Data,
}
if userText, found := getUserText(data); found {
response.UserText = userText
}
result.Response = response
}
handleNotificationResult(result)
})
// Register the COM class factory for toast activation.
// This is required for Windows to activate the app when users interact with notifications.
// The go-toast library's SetAppData and SetActivationCallback handle the callback setup,
// but the COM class factory registration is not exposed via public APIs, so we use
// go:linkname to access the internal registerClassFactory function.
if err := registerToastClassFactory(wintoast.ClassFactory); err != nil {
return fmt.Errorf("CoRegisterClassObject failed: %w", err)
}
return loadCategoriesFromRegistry()
}
// registerToastClassFactory registers the COM class factory required for Windows toast notification activation.
// This function uses go:linkname to access the unexported registerClassFactory function from go-toast.
// The class factory is necessary for Windows COM activation when users click notification actions.
// Without this registration, notification actions will not activate the application.
//
// This is a workaround until go-toast exports this functionality via a public API.
// See: https://git.sr.ht/~jackmordaunt/go-toast
//
//go:linkname registerToastClassFactory git.sr.ht/~jackmordaunt/go-toast/v2/wintoast.registerClassFactory
func registerToastClassFactory(factory *wintoast.IClassFactory) error
// CleanupNotifications is a Windows stub that does nothing.
// (Linux-specific cleanup)
func (f *Frontend) CleanupNotifications() {
// No cleanup needed on Windows
}
func (f *Frontend) IsNotificationAvailable() bool {
return true
}
func (f *Frontend) RequestNotificationAuthorization() (bool, error) {
return true, nil
}
func (f *Frontend) CheckNotificationAuthorization() (bool, error) {
return true, nil
}
// SendNotification sends a basic notification with a name, title, and body. All other options are ignored on Windows.
// (subtitle is only available on macOS and Linux)
func (f *Frontend) SendNotification(options frontend.NotificationOptions) error {
if err := f.saveIconToDir(); err != nil {
f.logger.Warning("Error saving icon: %v", err)
}
n := toast.Notification{
Title: options.Title,
Body: options.Body,
ActivationType: toast.Foreground,
ActivationArguments: DefaultActionIdentifier,
}
encodedPayload, err := encodePayload(DefaultActionIdentifier, options)
if err != nil {
return fmt.Errorf("failed to encode notification payload: %w", err)
}
n.ActivationArguments = encodedPayload
return n.Push()
}
// SendNotificationWithActions sends a notification with additional actions and inputs.
// A NotificationCategory must be registered with RegisterNotificationCategory first. The `CategoryID` must match the registered category.
// If a NotificationCategory is not registered a basic notification will be sent.
// (subtitle is only available on macOS and Linux)
func (f *Frontend) SendNotificationWithActions(options frontend.NotificationOptions) error {
if err := f.saveIconToDir(); err != nil {
f.logger.Warning("Error saving icon: %v", err)
}
categoriesLock.RLock()
nCategory, categoryExists := categories[options.CategoryID]
categoriesLock.RUnlock()
if options.CategoryID == "" || !categoryExists {
f.logger.Warning("Category '%s' not found, sending basic notification without actions", options.CategoryID)
return f.SendNotification(options)
}
n := toast.Notification{
Title: options.Title,
Body: options.Body,
ActivationType: toast.Foreground,
ActivationArguments: DefaultActionIdentifier,
}
for _, action := range nCategory.Actions {
n.Actions = append(n.Actions, toast.Action{
Content: action.Title,
Arguments: action.ID,
})
}
if nCategory.HasReplyField {
n.Inputs = append(n.Inputs, toast.Input{
ID: "userText",
Placeholder: nCategory.ReplyPlaceholder,
})
n.Actions = append(n.Actions, toast.Action{
Content: nCategory.ReplyButtonTitle,
Arguments: "TEXT_REPLY",
InputID: "userText",
})
}
encodedPayload, err := encodePayload(n.ActivationArguments, options)
if err != nil {
return fmt.Errorf("failed to encode notification payload: %w", err)
}
n.ActivationArguments = encodedPayload
for index := range n.Actions {
encodedPayload, err := encodePayload(n.Actions[index].Arguments, options)
if err != nil {
return fmt.Errorf("failed to encode notification payload: %w", err)
}
n.Actions[index].Arguments = encodedPayload
}
return n.Push()
}
// 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 (f *Frontend) RegisterNotificationCategory(category frontend.NotificationCategory) error {
categoriesLock.Lock()
defer categoriesLock.Unlock()
categories[category.ID] = frontend.NotificationCategory{
ID: category.ID,
Actions: category.Actions,
HasReplyField: category.HasReplyField,
ReplyPlaceholder: category.ReplyPlaceholder,
ReplyButtonTitle: category.ReplyButtonTitle,
}
return saveCategoriesToRegistry()
}
// RemoveNotificationCategory removes a previously registered NotificationCategory.
func (f *Frontend) RemoveNotificationCategory(categoryId string) error {
categoriesLock.Lock()
defer categoriesLock.Unlock()
delete(categories, categoryId)
return saveCategoriesToRegistry()
}
// RemoveAllPendingNotifications is a Windows stub that always returns nil.
// (macOS and Linux only)
func (f *Frontend) RemoveAllPendingNotifications() error {
return nil
}
// RemovePendingNotification is a Windows stub that always returns nil.
// (macOS and Linux only)
func (f *Frontend) RemovePendingNotification(_ string) error {
return nil
}
// RemoveAllDeliveredNotifications is a Windows stub that always returns nil.
// (macOS and Linux only)
func (f *Frontend) RemoveAllDeliveredNotifications() error {
return nil
}
// RemoveDeliveredNotification is a Windows stub that always returns nil.
// (macOS and Linux only)
func (f *Frontend) RemoveDeliveredNotification(_ string) error {
return nil
}
// RemoveNotification is a Windows stub that always returns nil.
// (Linux-specific)
func (f *Frontend) RemoveNotification(identifier string) error {
return nil
}
func (f *Frontend) OnNotificationResponse(callback func(result frontend.NotificationResult)) {
callbackLock.Lock()
defer callbackLock.Unlock()
notificationResultCallback = callback
}
func (f *Frontend) saveIconToDir() error {
iconOnce.Do(func() {
hIcon := w32.ExtractIcon(exePath, 0)
if hIcon == 0 {
iconErr = fmt.Errorf("ExtractIcon failed for %s", exePath)
return
}
defer w32.DestroyIcon(hIcon)
iconErr = winc.SaveHIconAsPNG(hIcon, iconPath)
})
return iconErr
}
func saveCategoriesToRegistry() error {
// We assume lock is held by caller
registryPath := fmt.Sprintf(NotificationCategoriesRegistryPath, appName)
key, _, err := registry.CreateKey(
registry.CURRENT_USER,
registryPath,
registry.ALL_ACCESS,
)
if err != nil {
return err
}
defer key.Close()
data, err := json.Marshal(categories)
if err != nil {
return err
}
return key.SetStringValue(NotificationCategoriesRegistryKey, string(data))
}
func loadCategoriesFromRegistry() error {
// We assume lock is held by caller
registryPath := fmt.Sprintf(NotificationCategoriesRegistryPath, appName)
key, err := registry.OpenKey(
registry.CURRENT_USER,
registryPath,
registry.QUERY_VALUE,
)
if err != nil {
if err == registry.ErrNotExist {
// Not an error, no saved categories
return nil
}
return fmt.Errorf("failed to open registry key: %w", err)
}
defer key.Close()
data, _, err := key.GetStringValue(NotificationCategoriesRegistryKey)
if err != nil {
if err == registry.ErrNotExist {
// No value yet, but key exists
return nil
}
return fmt.Errorf("failed to read categories from registry: %w", err)
}
_categories := make(map[string]frontend.NotificationCategory)
if err := json.Unmarshal([]byte(data), &_categories); err != nil {
return fmt.Errorf("failed to parse notification categories from registry: %w", err)
}
categories = _categories
return nil
}
func getUserText(data []toast.UserData) (string, bool) {
for _, d := range data {
if d.Key == "userText" {
return d.Value, true
}
}
return "", false
}
// encodePayload combines an action ID and user data into a single encoded string
func encodePayload(actionID string, options frontend.NotificationOptions) (string, error) {
payload := NotificationPayload{
Action: actionID,
Options: options,
}
jsonData, err := json.Marshal(payload)
if err != nil {
return actionID, err
}
encodedPayload := base64.StdEncoding.EncodeToString(jsonData)
return encodedPayload, nil
}
// decodePayload extracts the action ID and user data from an encoded payload
func decodePayload(encodedString string) (string, frontend.NotificationOptions, error) {
jsonData, err := base64.StdEncoding.DecodeString(encodedString)
if err != nil {
return encodedString, frontend.NotificationOptions{}, fmt.Errorf("failed to decode base64 payload: %w", err)
}
var payload NotificationPayload
if err := json.Unmarshal(jsonData, &payload); err != nil {
return encodedString, frontend.NotificationOptions{}, fmt.Errorf("failed to unmarshal notification payload: %w", err)
}
return payload.Action, payload.Options, nil
}
// parseNotificationResponse updated to use structured payload decoding
func parseNotificationResponse(response string) (action string, options frontend.NotificationOptions, err error) {
actionID, options, err := decodePayload(response)
if err != nil {
log.Printf("Warning: Failed to decode notification response: %v", err)
return response, frontend.NotificationOptions{}, err
}
return actionID, options, nil
}
func handleNotificationResult(result frontend.NotificationResult) {
callbackLock.RLock()
callback := notificationResultCallback
callbackLock.RUnlock()
if callback != nil {
go func() {
defer func() {
if r := recover(); r != nil {
// Log panic but don't crash the app
fmt.Fprintf(os.Stderr, "panic in notification callback: %v\n", r)
}
}()
callback(result)
}()
}
}
// Helper functions
func getGUID() (string, error) {
keyPath := ToastRegistryPath + appName
k, err := registry.OpenKey(registry.CURRENT_USER, keyPath, registry.QUERY_VALUE)
if err == nil {
guid, _, err := k.GetStringValue(ToastRegistryGuidKey)
k.Close()
if err == nil && guid != "" {
return guid, nil
}
}
guid := generateGUID()
k, _, err = registry.CreateKey(registry.CURRENT_USER, keyPath, registry.WRITE)
if err != nil {
return "", fmt.Errorf("failed to create registry key: %w", err)
}
defer k.Close()
if err := k.SetStringValue(ToastRegistryGuidKey, guid); err != nil {
return "", fmt.Errorf("failed to write GUID to registry: %w", err)
}
return guid, nil
}
func generateGUID() string {
guid := uuid.New()
return fmt.Sprintf("{%s}", guid.String())
}

View file

@ -10,11 +10,86 @@ package winc
import (
"errors"
"fmt"
"image"
"image/png"
"os"
"syscall"
"unsafe"
"github.com/wailsapp/wails/v2/internal/frontend/desktop/windows/winc/w32"
)
var (
user32 = syscall.NewLazyDLL("user32.dll")
gdi32 = syscall.NewLazyDLL("gdi32.dll")
procGetIconInfo = user32.NewProc("GetIconInfo")
procDeleteObject = gdi32.NewProc("DeleteObject")
procGetObject = gdi32.NewProc("GetObjectW")
procGetDIBits = gdi32.NewProc("GetDIBits")
procCreateCompatibleDC = gdi32.NewProc("CreateCompatibleDC")
procSelectObject = gdi32.NewProc("SelectObject")
procDeleteDC = gdi32.NewProc("DeleteDC")
)
func init() {
// Validate DLL loads at initialization time to surface missing APIs early
if err := user32.Load(); err != nil {
panic(fmt.Sprintf("failed to load user32.dll: %v", err))
}
if err := gdi32.Load(); err != nil {
panic(fmt.Sprintf("failed to load gdi32.dll: %v", err))
}
}
// ICONINFO mirrors the Win32 ICONINFO struct
type ICONINFO struct {
FIcon int32
XHotspot uint32
YHotspot uint32
HbmMask uintptr
HbmColor uintptr
}
// http://msdn.microsoft.com/en-us/library/windows/desktop/dd183376.aspx
type BITMAPINFOHEADER struct {
BiSize uint32
BiWidth int32
BiHeight int32
BiPlanes uint16
BiBitCount uint16
BiCompression uint32
BiSizeImage uint32
BiXPelsPerMeter int32
BiYPelsPerMeter int32
BiClrUsed uint32
BiClrImportant uint32
}
// http://msdn.microsoft.com/en-us/library/windows/desktop/dd162938.aspx
type RGBQUAD struct {
RgbBlue byte
RgbGreen byte
RgbRed byte
RgbReserved byte
}
// http://msdn.microsoft.com/en-us/library/windows/desktop/dd183375.aspx
type BITMAPINFO struct {
BmiHeader BITMAPINFOHEADER
BmiColors *RGBQUAD
}
// http://msdn.microsoft.com/en-us/library/windows/desktop/dd183371.aspx
type BITMAP struct {
BmType int32
BmWidth int32
BmHeight int32
BmWidthBytes int32
BmPlanes uint16
BmBitsPixel uint16
BmBits unsafe.Pointer
}
type Icon struct {
handle w32.HICON
}
@ -46,6 +121,95 @@ func ExtractIcon(fileName string, index int) (*Icon, error) {
return ico, err
}
func SaveHIconAsPNG(hIcon w32.HICON, filePath string) error {
// Get icon info
var iconInfo ICONINFO
ret, _, err := procGetIconInfo.Call(
uintptr(hIcon),
uintptr(unsafe.Pointer(&iconInfo)),
)
if ret == 0 {
return err
}
defer procDeleteObject.Call(uintptr(iconInfo.HbmMask))
defer procDeleteObject.Call(uintptr(iconInfo.HbmColor))
// Get bitmap info
var bmp BITMAP
ret, _, err = procGetObject.Call(
uintptr(iconInfo.HbmColor),
unsafe.Sizeof(bmp),
uintptr(unsafe.Pointer(&bmp)),
)
if ret == 0 {
return err
}
// Get screen DC for GetDIBits (bitmap must not be selected into a DC)
screenDC := w32.GetDC(0)
if screenDC == 0 {
return fmt.Errorf("failed to get screen DC")
}
defer w32.ReleaseDC(0, screenDC)
// Prepare bitmap info header
var bi BITMAPINFO
bi.BmiHeader.BiSize = uint32(unsafe.Sizeof(bi.BmiHeader))
bi.BmiHeader.BiWidth = bmp.BmWidth
bi.BmiHeader.BiHeight = bmp.BmHeight
bi.BmiHeader.BiPlanes = 1
bi.BmiHeader.BiBitCount = 32
bi.BmiHeader.BiCompression = w32.BI_RGB
// Allocate memory for bitmap bits
width, height := int(bmp.BmWidth), int(bmp.BmHeight)
bufferSize := width * height * 4
bits := make([]byte, bufferSize)
// Get bitmap bits using screen DC (bitmap must not be selected into any DC)
ret, _, err = procGetDIBits.Call(
uintptr(screenDC),
uintptr(iconInfo.HbmColor),
0,
uintptr(bmp.BmHeight),
uintptr(unsafe.Pointer(&bits[0])),
uintptr(unsafe.Pointer(&bi)),
w32.DIB_RGB_COLORS,
)
if ret == 0 {
return fmt.Errorf("failed to get bitmap bits: %w", err)
}
// Create Go image
img := image.NewRGBA(image.Rect(0, 0, width, height))
// Convert DIB to RGBA
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
// DIB is bottom-up, so we need to invert Y
dibIndex := ((height-1-y)*width + x) * 4
// RGBA image is top-down
imgIndex := (y*width + x) * 4
// BGRA to RGBA
img.Pix[imgIndex] = bits[dibIndex+2] // R
img.Pix[imgIndex+1] = bits[dibIndex+1] // G
img.Pix[imgIndex+2] = bits[dibIndex] // B
img.Pix[imgIndex+3] = bits[dibIndex+3] // A
}
}
// Create output file
outFile, err := os.Create(filePath)
if err != nil {
return err
}
defer outFile.Close()
// Encode and save the image
return png.Encode(outFile, img)
}
func (ic *Icon) Destroy() bool {
return w32.DestroyIcon(ic.handle)
}

View file

@ -61,6 +61,102 @@ func (d *Dispatcher) processSystemCall(payload callMessage, sender frontend.Fron
return false, err
}
return true, nil
case "InitializeNotifications":
err := sender.InitializeNotifications()
return nil, err
case "CleanupNotifications":
sender.CleanupNotifications()
return nil, nil
case "IsNotificationAvailable":
return sender.IsNotificationAvailable(), nil
case "RequestNotificationAuthorization":
authorized, err := sender.RequestNotificationAuthorization()
if err != nil {
return nil, err
}
return authorized, nil
case "CheckNotificationAuthorization":
authorized, err := sender.CheckNotificationAuthorization()
if err != nil {
return nil, err
}
return authorized, nil
case "SendNotification":
if len(payload.Args) < 1 {
return nil, errors.New("empty argument, cannot send notification")
}
var options frontend.NotificationOptions
if err := json.Unmarshal(payload.Args[0], &options); err != nil {
return nil, err
}
err := sender.SendNotification(options)
return nil, err
case "SendNotificationWithActions":
if len(payload.Args) < 1 {
return nil, errors.New("empty argument, cannot send notification")
}
var options frontend.NotificationOptions
if err := json.Unmarshal(payload.Args[0], &options); err != nil {
return nil, err
}
err := sender.SendNotificationWithActions(options)
return nil, err
case "RegisterNotificationCategory":
if len(payload.Args) < 1 {
return nil, errors.New("empty argument, cannot register category")
}
var category frontend.NotificationCategory
if err := json.Unmarshal(payload.Args[0], &category); err != nil {
return nil, err
}
err := sender.RegisterNotificationCategory(category)
return nil, err
case "RemoveNotificationCategory":
if len(payload.Args) < 1 {
return nil, errors.New("empty argument, cannot remove category")
}
var categoryId string
if err := json.Unmarshal(payload.Args[0], &categoryId); err != nil {
return nil, err
}
err := sender.RemoveNotificationCategory(categoryId)
return nil, err
case "RemoveAllPendingNotifications":
err := sender.RemoveAllPendingNotifications()
return nil, err
case "RemovePendingNotification":
if len(payload.Args) < 1 {
return nil, errors.New("empty argument, cannot remove notification")
}
var identifier string
if err := json.Unmarshal(payload.Args[0], &identifier); err != nil {
return nil, err
}
err := sender.RemovePendingNotification(identifier)
return nil, err
case "RemoveAllDeliveredNotifications":
err := sender.RemoveAllDeliveredNotifications()
return nil, err
case "RemoveDeliveredNotification":
if len(payload.Args) < 1 {
return nil, errors.New("empty argument, cannot remove notification")
}
var identifier string
if err := json.Unmarshal(payload.Args[0], &identifier); err != nil {
return nil, err
}
err := sender.RemoveDeliveredNotification(identifier)
return nil, err
case "RemoveNotification":
if len(payload.Args) < 1 {
return nil, errors.New("empty argument, cannot remove notification")
}
var identifier string
if err := json.Unmarshal(payload.Args[0], &identifier); err != nil {
return nil, err
}
err := sender.RemoveNotification(identifier)
return nil, err
default:
return nil, fmt.Errorf("unknown systemcall message: %s", payload.Name)
}

View file

@ -76,6 +76,51 @@ type MessageDialogOptions struct {
Icon []byte
}
// NotificationOptions contains configuration for a notification.
type NotificationOptions struct {
ID string `json:"id"`
Title string `json:"title"`
Subtitle string `json:"subtitle,omitempty"` // (macOS and Linux only)
Body string `json:"body,omitempty"`
CategoryID string `json:"categoryId,omitempty"`
Data map[string]interface{} `json:"data,omitempty"`
}
// NotificationAction represents an action button for a notification.
type NotificationAction struct {
ID string `json:"id,omitempty"`
Title string `json:"title,omitempty"`
Destructive bool `json:"destructive,omitempty"` // (macOS-specific)
}
// NotificationCategory groups actions for notifications.
type NotificationCategory struct {
ID string `json:"id,omitempty"`
Actions []NotificationAction `json:"actions,omitempty"`
HasReplyField bool `json:"hasReplyField,omitempty"`
ReplyPlaceholder string `json:"replyPlaceholder,omitempty"`
ReplyButtonTitle string `json:"replyButtonTitle,omitempty"`
}
// NotificationResponse represents the response sent by interacting with a notification.
type NotificationResponse struct {
ID string `json:"id,omitempty"`
ActionIdentifier string `json:"actionIdentifier,omitempty"`
CategoryID string `json:"categoryId,omitempty"` // Consistent with NotificationOptions
Title string `json:"title,omitempty"`
Subtitle string `json:"subtitle,omitempty"` // (macOS and Linux only)
Body string `json:"body,omitempty"`
UserText string `json:"userText,omitempty"`
UserInfo map[string]interface{} `json:"userInfo,omitempty"`
}
// NotificationResult represents the result of a notification response,
// returning the response or any errors that occurred.
type NotificationResult struct {
Response NotificationResponse
Error error
}
type Frontend interface {
Run(ctx context.Context) error
RunMainLoop()
@ -139,4 +184,21 @@ type Frontend interface {
// Clipboard
ClipboardGetText() (string, error)
ClipboardSetText(text string) error
// Notifications
InitializeNotifications() error
CleanupNotifications()
IsNotificationAvailable() bool
RequestNotificationAuthorization() (bool, error)
CheckNotificationAuthorization() (bool, error)
OnNotificationResponse(callback func(result NotificationResult))
SendNotification(options NotificationOptions) error
SendNotificationWithActions(options NotificationOptions) error
RegisterNotificationCategory(category NotificationCategory) error
RemoveNotificationCategory(categoryId string) error
RemoveAllPendingNotifications() error
RemovePendingNotification(identifier string) error
RemoveAllDeliveredNotifications() error
RemoveDeliveredNotification(identifier string) error
RemoveNotification(identifier string) error
}

View file

@ -27,6 +27,7 @@ import * as Browser from "./browser";
import * as Clipboard from "./clipboard";
import * as DragAndDrop from "./draganddrop";
import * as ContextMenu from "./contextmenu";
import * as Notifications from "./notifications";
export function Quit() {
window.WailsInvoke('Q');
@ -52,6 +53,7 @@ window.runtime = {
...Screen,
...Clipboard,
...DragAndDrop,
...Notifications,
EventsOn,
EventsOnce,
EventsOnMultiple,

View file

@ -0,0 +1,200 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/
/* jshint esversion: 9 */
import {Call} from "./calls";
/**
* Initialize the notification service for the application.
* This must be called before sending any notifications.
* On macOS, this also ensures the notification delegate is properly initialized.
*
* @export
* @return {Promise<void>}
*/
export function InitializeNotifications() {
return Call(":wails:InitializeNotifications");
}
/**
* Clean up notification resources and release any held connections.
* This should be called when shutting down the application to properly release resources
* (primarily needed on Linux to close D-Bus connections).
*
* @export
* @return {Promise<void>}
*/
export function CleanupNotifications() {
return Call(":wails:CleanupNotifications");
}
/**
* Check if notifications are available on the current platform.
*
* @export
* @return {Promise<boolean>} True if notifications are available, false otherwise
*/
export function IsNotificationAvailable() {
return Call(":wails:IsNotificationAvailable");
}
/**
* Request notification authorization from the user.
* On macOS, this prompts the user to allow notifications.
* On other platforms, this always returns true.
*
* @export
* @return {Promise<boolean>} True if authorization was granted, false otherwise
*/
export function RequestNotificationAuthorization() {
return Call(":wails:RequestNotificationAuthorization");
}
/**
* Check the current notification authorization status.
* On macOS, this checks if the app has notification permissions.
* On other platforms, this always returns true.
*
* @export
* @return {Promise<boolean>} True if authorized, false otherwise
*/
export function CheckNotificationAuthorization() {
return Call(":wails:CheckNotificationAuthorization");
}
/**
* Send a basic notification with the given options.
* The notification will display with the provided title, subtitle (if supported), and body text.
*
* @export
* @param {Object} options - Notification options
* @param {string} options.id - Unique identifier for the notification
* @param {string} options.title - Notification title
* @param {string} [options.subtitle] - Notification subtitle (macOS and Linux only)
* @param {string} [options.body] - Notification body text
* @param {string} [options.categoryId] - Category ID for action buttons (requires SendNotificationWithActions)
* @param {Object<string, any>} [options.data] - Additional user data to attach to the notification
* @return {Promise<void>}
*/
export function SendNotification(options) {
return Call(":wails:SendNotification", [options]);
}
/**
* Send a notification with action buttons.
* A NotificationCategory must be registered first using RegisterNotificationCategory.
* The options.categoryId must match a previously registered category ID.
* If the category is not found, a basic notification will be sent instead.
*
* @export
* @param {Object} options - Notification options
* @param {string} options.id - Unique identifier for the notification
* @param {string} options.title - Notification title
* @param {string} [options.subtitle] - Notification subtitle (macOS and Linux only)
* @param {string} [options.body] - Notification body text
* @param {string} options.categoryId - Category ID that matches a registered category
* @param {Object<string, any>} [options.data] - Additional user data to attach to the notification
* @return {Promise<void>}
*/
export function SendNotificationWithActions(options) {
return Call(":wails:SendNotificationWithActions", [options]);
}
/**
* Register a notification category that can be used with SendNotificationWithActions.
* Categories define the action buttons and optional reply fields that will appear on notifications.
* Registering a category with the same ID as a previously registered category will override it.
*
* @export
* @param {Object} category - Notification category definition
* @param {string} category.id - Unique identifier for the category
* @param {Array<Object>} [category.actions] - Array of action buttons
* @param {string} category.actions[].id - Unique identifier for the action
* @param {string} category.actions[].title - Display title for the action button
* @param {boolean} [category.actions[].destructive] - Whether the action is destructive (macOS-specific)
* @param {boolean} [category.hasReplyField] - Whether to include a text input field for replies
* @param {string} [category.replyPlaceholder] - Placeholder text for the reply field (required if hasReplyField is true)
* @param {string} [category.replyButtonTitle] - Title for the reply button (required if hasReplyField is true)
* @return {Promise<void>}
*/
export function RegisterNotificationCategory(category) {
return Call(":wails:RegisterNotificationCategory", [category]);
}
/**
* Remove a previously registered notification category.
*
* @export
* @param {string} categoryId - The ID of the category to remove
* @return {Promise<void>}
*/
export function RemoveNotificationCategory(categoryId) {
return Call(":wails:RemoveNotificationCategory", [categoryId]);
}
/**
* Remove all pending notifications from the notification center.
* On Windows, this is a no-op as the platform manages notification lifecycle automatically.
*
* @export
* @return {Promise<void>}
*/
export function RemoveAllPendingNotifications() {
return Call(":wails:RemoveAllPendingNotifications");
}
/**
* Remove a specific pending notification by its identifier.
* On Windows, this is a no-op as the platform manages notification lifecycle automatically.
*
* @export
* @param {string} identifier - The ID of the notification to remove
* @return {Promise<void>}
*/
export function RemovePendingNotification(identifier) {
return Call(":wails:RemovePendingNotification", [identifier]);
}
/**
* Remove all delivered notifications from the notification center.
* On Windows, this is a no-op as the platform manages notification lifecycle automatically.
*
* @export
* @return {Promise<void>}
*/
export function RemoveAllDeliveredNotifications() {
return Call(":wails:RemoveAllDeliveredNotifications");
}
/**
* Remove a specific delivered notification by its identifier.
* On Windows, this is a no-op as the platform manages notification lifecycle automatically.
*
* @export
* @param {string} identifier - The ID of the notification to remove
* @return {Promise<void>}
*/
export function RemoveDeliveredNotification(identifier) {
return Call(":wails:RemoveDeliveredNotification", [identifier]);
}
/**
* Remove a notification by its identifier.
* This is a convenience function that works across platforms.
* On macOS, use the more specific RemovePendingNotification or RemoveDeliveredNotification functions.
*
* @export
* @param {string} identifier - The ID of the notification to remove
* @return {Promise<void>}
*/
export function RemoveNotification(identifier) {
return Call(":wails:RemoveNotification", [identifier]);
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -246,4 +246,85 @@ export function OnFileDropOff() :void
export function CanResolveFilePaths(): boolean;
// Resolves file paths for an array of files
export function ResolveFilePaths(files: File[]): void
export function ResolveFilePaths(files: File[]): void
// Notification types
export interface NotificationOptions {
id: string;
title: string;
subtitle?: string; // macOS and Linux only
body?: string;
categoryId?: string;
data?: { [key: string]: any };
}
export interface NotificationAction {
id?: string;
title?: string;
destructive?: boolean; // macOS-specific
}
export interface NotificationCategory {
id?: string;
actions?: NotificationAction[];
hasReplyField?: boolean;
replyPlaceholder?: string;
replyButtonTitle?: string;
}
// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications)
// Initializes the notification service for the application.
// This must be called before sending any notifications.
export function InitializeNotifications(): Promise<void>;
// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications)
// Cleans up notification resources and releases any held connections.
export function CleanupNotifications(): Promise<void>;
// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable)
// Checks if notifications are available on the current platform.
export function IsNotificationAvailable(): Promise<boolean>;
// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization)
// Requests notification authorization from the user (macOS only).
export function RequestNotificationAuthorization(): Promise<boolean>;
// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization)
// Checks the current notification authorization status (macOS only).
export function CheckNotificationAuthorization(): Promise<boolean>;
// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification)
// Sends a basic notification with the given options.
export function SendNotification(options: NotificationOptions): Promise<void>;
// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions)
// Sends a notification with action buttons. Requires a registered category.
export function SendNotificationWithActions(options: NotificationOptions): Promise<void>;
// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory)
// Registers a notification category that can be used with SendNotificationWithActions.
export function RegisterNotificationCategory(category: NotificationCategory): Promise<void>;
// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory)
// Removes a previously registered notification category.
export function RemoveNotificationCategory(categoryId: string): Promise<void>;
// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications)
// Removes all pending notifications from the notification center.
export function RemoveAllPendingNotifications(): Promise<void>;
// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification)
// Removes a specific pending notification by its identifier.
export function RemovePendingNotification(identifier: string): Promise<void>;
// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications)
// Removes all delivered notifications from the notification center.
export function RemoveAllDeliveredNotifications(): Promise<void>;
// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification)
// Removes a specific delivered notification by its identifier.
export function RemoveDeliveredNotification(identifier: string): Promise<void>;
// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification)
// Removes a notification by its identifier (cross-platform convenience function).
export function RemoveNotification(identifier: string): Promise<void>;

View file

@ -239,4 +239,60 @@ export function CanResolveFilePaths() {
export function ResolveFilePaths(files) {
return window.runtime.ResolveFilePaths(files);
}
export function InitializeNotifications() {
return window.runtime.InitializeNotifications();
}
export function CleanupNotifications() {
return window.runtime.CleanupNotifications();
}
export function IsNotificationAvailable() {
return window.runtime.IsNotificationAvailable();
}
export function RequestNotificationAuthorization() {
return window.runtime.RequestNotificationAuthorization();
}
export function CheckNotificationAuthorization() {
return window.runtime.CheckNotificationAuthorization();
}
export function SendNotification(options) {
return window.runtime.SendNotification(options);
}
export function SendNotificationWithActions(options) {
return window.runtime.SendNotificationWithActions(options);
}
export function RegisterNotificationCategory(category) {
return window.runtime.RegisterNotificationCategory(category);
}
export function RemoveNotificationCategory(categoryId) {
return window.runtime.RemoveNotificationCategory(categoryId);
}
export function RemoveAllPendingNotifications() {
return window.runtime.RemoveAllPendingNotifications();
}
export function RemovePendingNotification(identifier) {
return window.runtime.RemovePendingNotification(identifier);
}
export function RemoveAllDeliveredNotifications() {
return window.runtime.RemoveAllDeliveredNotifications();
}
export function RemoveDeliveredNotification(identifier) {
return window.runtime.RemoveDeliveredNotification(identifier);
}
export function RemoveNotification(identifier) {
return window.runtime.RemoveNotification(identifier);
}

View file

@ -3,6 +3,7 @@ package build
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
@ -357,6 +358,16 @@ func execBuildApplication(builder Builder, options *Options) (string, error) {
pterm.Println("Done.")
}
if runtime.GOOS == "darwin" && options.Platform == "darwin" {
// On macOS, self-sign the .app bundle so notifications work
printBulletPoint("Self-signing application: ")
cmd := exec.Command("/usr/bin/codesign", "--force", "--deep", "--sign", "-", options.CompiledBinary)
if out, err := cmd.CombinedOutput(); err != nil {
return "", fmt.Errorf("codesign failed: %v %s", err, out)
}
pterm.Println("Done.")
}
if options.Platform == "windows" {
const nativeWebView2Loader = "native_webview2loader"

View file

@ -0,0 +1,136 @@
package runtime
import (
"context"
"github.com/wailsapp/wails/v2/internal/frontend"
)
// NotificationOptions contains configuration for a notification.
type NotificationOptions = frontend.NotificationOptions
// NotificationAction represents an action button for a notification.
type NotificationAction = frontend.NotificationAction
// NotificationCategory groups actions for notifications.
type NotificationCategory = frontend.NotificationCategory
// NotificationResponse represents the response sent by interacting with a notification.
type NotificationResponse = frontend.NotificationResponse
// NotificationResult represents the result of a notification response,
// returning the response or any errors that occurred.
type NotificationResult = frontend.NotificationResult
// InitializeNotifications initializes the notification service for the application.
// This must be called before sending any notifications. On macOS, this also ensures
// the notification delegate is properly initialized.
func InitializeNotifications(ctx context.Context) error {
fe := getFrontend(ctx)
return fe.InitializeNotifications()
}
// CleanupNotifications cleans up notification resources and releases any held connections.
// This should be called when shutting down the application to properly release resources
// (primarily needed on Linux to close D-Bus connections).
func CleanupNotifications(ctx context.Context) {
fe := getFrontend(ctx)
fe.CleanupNotifications()
}
// IsNotificationAvailable checks if notifications are available on the current platform.
func IsNotificationAvailable(ctx context.Context) bool {
fe := getFrontend(ctx)
return fe.IsNotificationAvailable()
}
// RequestNotificationAuthorization requests notification authorization from the user.
// On macOS, this prompts the user to allow notifications. On other platforms, this
// always returns true. Returns true if authorization was granted, false otherwise.
func RequestNotificationAuthorization(ctx context.Context) (bool, error) {
fe := getFrontend(ctx)
return fe.RequestNotificationAuthorization()
}
// CheckNotificationAuthorization checks the current notification authorization status.
// On macOS, this checks if the app has notification permissions. On other platforms,
// this always returns true.
func CheckNotificationAuthorization(ctx context.Context) (bool, error) {
fe := getFrontend(ctx)
return fe.CheckNotificationAuthorization()
}
// SendNotification sends a basic notification with the given options.
// The notification will display with the provided title, subtitle (if supported),
// and body text.
func SendNotification(ctx context.Context, options NotificationOptions) error {
fe := getFrontend(ctx)
return fe.SendNotification(options)
}
// SendNotificationWithActions sends a notification with action buttons.
// A NotificationCategory must be registered first using RegisterNotificationCategory.
// The options.CategoryID must match a previously registered category ID.
// If the category is not found, a basic notification will be sent instead.
func SendNotificationWithActions(ctx context.Context, options NotificationOptions) error {
fe := getFrontend(ctx)
return fe.SendNotificationWithActions(options)
}
// RegisterNotificationCategory registers a notification category that can be used
// with SendNotificationWithActions. Categories define the action buttons and optional
// reply fields that will appear on notifications.
func RegisterNotificationCategory(ctx context.Context, category NotificationCategory) error {
fe := getFrontend(ctx)
return fe.RegisterNotificationCategory(category)
}
// RemoveNotificationCategory removes a previously registered notification category.
func RemoveNotificationCategory(ctx context.Context, categoryId string) error {
fe := getFrontend(ctx)
return fe.RemoveNotificationCategory(categoryId)
}
// RemoveAllPendingNotifications removes all pending notifications from the notification center.
// On Windows, this is a no-op as the platform manages notification lifecycle automatically.
func RemoveAllPendingNotifications(ctx context.Context) error {
fe := getFrontend(ctx)
return fe.RemoveAllPendingNotifications()
}
// RemovePendingNotification removes a specific pending notification by its identifier.
// On Windows, this is a no-op as the platform manages notification lifecycle automatically.
func RemovePendingNotification(ctx context.Context, identifier string) error {
fe := getFrontend(ctx)
return fe.RemovePendingNotification(identifier)
}
// RemoveAllDeliveredNotifications removes all delivered notifications from the notification center.
// On Windows, this is a no-op as the platform manages notification lifecycle automatically.
func RemoveAllDeliveredNotifications(ctx context.Context) error {
fe := getFrontend(ctx)
return fe.RemoveAllDeliveredNotifications()
}
// RemoveDeliveredNotification removes a specific delivered notification by its identifier.
// On Windows, this is a no-op as the platform manages notification lifecycle automatically.
func RemoveDeliveredNotification(ctx context.Context, identifier string) error {
fe := getFrontend(ctx)
return fe.RemoveDeliveredNotification(identifier)
}
// RemoveNotification removes a notification by its identifier.
// This is a convenience function that works across platforms. On macOS, use the
// more specific RemovePendingNotification or RemoveDeliveredNotification functions.
func RemoveNotification(ctx context.Context, identifier string) error {
fe := getFrontend(ctx)
return fe.RemoveNotification(identifier)
}
// OnNotificationResponse registers a callback function that will be invoked when
// a user interacts with a notification (e.g., clicks an action button or the notification itself).
// The callback receives a NotificationResult containing the response details or any errors.
func OnNotificationResponse(ctx context.Context, callback func(result NotificationResult)) {
fe := getFrontend(ctx)
fe.OnNotificationResponse(callback)
}

View file

@ -0,0 +1,233 @@
# Notifications
Wails provides a comprehensive cross-platform notification system for desktop applications. This runtime allows you to display native system notifications with support for interactive elements like action buttons and text input fields.
:::info JavaScript
Notifications are currently unsupported in the JS runtime.
:::
## Basic Usage
### Initializing Notifications
First, initialize the notification system. This should be called during app startup (typically in `OnStartup`):
```go
err := runtime.InitializeNotifications(a.ctx)
if err != nil {
// Handle initialization error
// On macOS, this may fail if bundle identifier is not set
}
```
Then, check if notifications are available on the current platform:
```go
if runtime.IsNotificationAvailable(a.ctx) {
// Notifications are supported
// On macOS, this checks for macOS 10.14+
// On Windows and Linux, this always returns true
}
```
On macOS, you'll need to request permission before sending notifications:
```go
authorized, err := runtime.CheckNotificationAuthorization(a.ctx)
if err != nil {
// Handle authorization error
}
if !authorized {
authorized, err = runtime.RequestNotificationAuthorization(a.ctx)
if err != nil || !authorized {
// Handle permission denial
}
}
```
On Windows and Linux, authorization is not required as these platforms don't have permission systems.
### Sending Basic Notifications
Send a basic notification with a unique ID, title, optional subtitle (macOS and Linux), and body text:
```go
err := runtime.SendNotification(a.ctx, runtime.NotificationOptions{
ID: "calendar-invite-001",
Title: "New Calendar Invite",
Subtitle: "From: Jane Doe", // Optional - macOS and Linux only
Body: "Tap to view the event",
})
if err != nil {
// Handle error
}
```
## Interactive Notifications
Interactive notifications allow users to respond with button actions or text input. You must first register a notification category that defines the available actions.
### Creating Notification Categories
Define a category with action buttons and optional text input:
```go
categoryID := "message-category"
category := runtime.NotificationCategory{
ID: categoryID,
Actions: []runtime.NotificationAction{
{
ID: "OPEN",
Title: "Open",
},
{
ID: "ARCHIVE",
Title: "Archive",
Destructive: true, // macOS-specific - shows as red button
},
},
HasReplyField: true,
ReplyPlaceholder: "Type your reply...",
ReplyButtonTitle: "Reply",
}
err := runtime.RegisterNotificationCategory(a.ctx, category)
if err != nil {
// Handle error
}
```
### Sending Interactive Notifications
Send an interactive notification using the registered category. If the category is not found or `CategoryID` is empty, a basic notification will be sent instead:
```go
err := runtime.SendNotificationWithActions(a.ctx, runtime.NotificationOptions{
ID: "message-001",
Title: "New Message",
Subtitle: "From: John Smith", // Optional - macOS and Linux only
Body: "Hey, are you free for lunch?",
CategoryID: categoryID,
})
if err != nil {
// Handle error
}
```
## Handling Notification Responses
Listen for user interactions with notifications by registering a callback:
```go
runtime.OnNotificationResponse(a.ctx, func(result runtime.NotificationResult) {
if result.Error != nil {
// Handle response error
return
}
response := result.Response
fmt.Printf("Notification %s was actioned with: %s\n",
response.ID, response.ActionIdentifier)
if response.ActionIdentifier == "TEXT_REPLY" {
fmt.Printf("User replied: %s\n", response.UserText)
}
// You can also emit events to the frontend
runtime.EventsEmit(a.ctx, "notification", response)
})
```
## Adding Custom Data
Basic and interactive notifications can include custom data that will be returned in the response:
```go
err := runtime.SendNotification(a.ctx, runtime.NotificationOptions{
ID: "event-001",
Title: "Team Meeting",
Subtitle: "In 30 minutes",
Body: "Don't forget your presentation materials!",
Data: map[string]interface{}{
"eventId": "meeting-123",
"startTime": "2024-01-15T14:00:00Z",
"attendees": []string{"john@company.com", "jane@company.com"},
"priority": "high",
},
})
// In the response handler:
runtime.OnNotificationResponse(a.ctx, func(result runtime.NotificationResult) {
response := result.Response
if eventId, ok := response.UserInfo["eventId"].(string); ok {
fmt.Printf("Event ID: %s\n", eventId)
}
})
```
## Managing Notifications
### Removing Notification Categories
Remove a previously registered notification category:
```go
err := runtime.RemoveNotificationCategory(a.ctx, "message-category")
```
### Managing Notifications Lifecycle
Control notification visibility:
```go
// Remove a specific pending notification (macOS and Linux only)
err := runtime.RemovePendingNotification(a.ctx, "notification-id")
// Remove all pending notifications (macOS and Linux only)
err = runtime.RemoveAllPendingNotifications(a.ctx)
// Remove a specific delivered notification (macOS and Linux only)
err = runtime.RemoveDeliveredNotification(a.ctx, "notification-id")
// Remove all delivered notifications (macOS and Linux only)
err = runtime.RemoveAllDeliveredNotifications(a.ctx)
// Remove a notification (Linux-specific)
err = runtime.RemoveNotification(a.ctx, "notification-id")
```
## Platform Considerations
### macOS
- **Authorization Required**: Apps must request notification permission
- **Notarization**: Required for app distribution on macOS
- **Features**: Supports subtitles, user text input, destructive actions, dark/light mode
- **Behavior**: Notifications appear in the system notification center
### Windows
- **No Authorization**: No permission system required
- **Features**: Supports user text input, high DPI displays, Windows theme adaptation
- **Limitations**: Does not support subtitles
- **Behavior**: Uses Windows toast notifications
### Linux
- **Desktop Environment Dependent**: Behavior varies by DE (GNOME, KDE, etc.)
- **Features**: Supports subtitles and themes
- **Limitations**: Does not support user text input
- **Behavior**: Uses native notification system when available
## Best Practices
1. **Check Platform Support**: Always verify notifications are available before using them
2. **Handle Authorization**: Properly request and check permissions on macOS
3. **Use Descriptive Content**: Provide clear titles, subtitles, and action button labels
4. **Handle Responses**: Always implement proper error handling for notification responses
5. **Test Across Platforms**: Verify functionality on your target platforms
6. **Clean Up**: Remove old notification categories when they're no longer needed

View file

@ -0,0 +1,601 @@
---
sidebar_position: 6
---
# Notification
This part of the runtime provides access to native system notifications with support for interactive elements like action buttons and text input fields.
### InitializeNotifications
Initializes the notification system. It should be called during app startup.
**Go:** `InitializeNotifications(ctx context.Context) error`
**JavaScript:** `InitializeNotifications(): Promise<void>`
Returns: Error if initialization fails
**Example:**
```go
err := runtime.InitializeNotifications(ctx)
if err != nil {
log.Fatal(err)
}
```
```javascript
await runtime.InitializeNotifications();
```
### IsNotificationAvailable
Checks if notifications are supported on the current platform.
**Go:** `IsNotificationAvailable(ctx context.Context) bool`
**JavaScript:** `IsNotificationAvailable(): Promise<boolean>`
Returns: `true` if notifications are supported, `false` otherwise
**Example:**
```go
if !runtime.IsNotificationAvailable(ctx) {
log.Println("Notifications not available on this platform")
}
```
```javascript
const available = await runtime.IsNotificationAvailable();
if (!available) {
console.log("Notifications not available on this platform");
}
```
### RequestNotificationAuthorization
Requests permission to display notifications (macOS only). On Windows and Linux, this always returns `true`.
**Go:** `RequestNotificationAuthorization(ctx context.Context) (bool, error)`
**JavaScript:** `RequestNotificationAuthorization(): Promise<boolean>`
Returns: Authorization status and error
**Example:**
```go
authorized, err := runtime.RequestNotificationAuthorization(ctx)
```
```javascript
const authorized = await runtime.RequestNotificationAuthorization();
```
### CheckNotificationAuthorization
Checks the current notification authorization status (macOS only). On Windows and Linux, this always returns `true`.
**Go:** `CheckNotificationAuthorization(ctx context.Context) (bool, error)`
**JavaScript:** `CheckNotificationAuthorization(): Promise<boolean>`
Returns: Authorization status and error
**Example:**
```go
authorized, err := runtime.CheckNotificationAuthorization(ctx)
```
```javascript
const authorized = await runtime.CheckNotificationAuthorization();
```
### CleanupNotifications
Cleans up notification resources and releases any held connections. This should be called when shutting down the application, particularly on Linux where it closes the D-Bus connection.
**Go:** `CleanupNotifications(ctx context.Context)`
**JavaScript:** `CleanupNotifications(): Promise<void>`
**Example:**
```go
runtime.CleanupNotifications(ctx)
```
```javascript
await runtime.CleanupNotifications();
```
### SendNotification
Sends a basic notification to the system.
**Go:** `SendNotification(ctx context.Context, options NotificationOptions) error`
**JavaScript:** `SendNotification(options: NotificationOptions): Promise<void>`
Returns: Error if the notification fails to send
**Example:**
```go
err := runtime.SendNotification(ctx, runtime.NotificationOptions{
ID: "notif-1",
Title: "Hello",
Body: "This is a notification",
})
```
```javascript
await runtime.SendNotification({
id: "notif-1",
title: "Hello",
body: "This is a notification"
});
```
### SendNotificationWithActions
Sends an interactive notification with predefined actions. Requires a registered notification category. If the category is not found or `CategoryID` is empty, a basic notification will be sent instead.
**Go:** `SendNotificationWithActions(ctx context.Context, options NotificationOptions) error`
**JavaScript:** `SendNotificationWithActions(options: NotificationOptions): Promise<void>`
Returns: Error if the notification fails to send
**Example:**
```go
err := runtime.SendNotificationWithActions(ctx, runtime.NotificationOptions{
ID: "notif-2",
Title: "Task Reminder",
Body: "Complete your task",
CategoryID: "TASK_CATEGORY",
})
```
```javascript
await runtime.SendNotificationWithActions({
id: "notif-2",
title: "Task Reminder",
body: "Complete your task",
categoryId: "TASK_CATEGORY"
});
```
### RegisterNotificationCategory
Registers a notification category that can be used with interactive notifications. Registering a category with the same ID as a previously registered category will override it.
**Go:** `RegisterNotificationCategory(ctx context.Context, category NotificationCategory) error`
**JavaScript:** `RegisterNotificationCategory(category: NotificationCategory): Promise<void>`
Returns: Error if registration fails
**Example:**
```go
err := runtime.RegisterNotificationCategory(ctx, runtime.NotificationCategory{
ID: "TASK_CATEGORY",
Actions: []runtime.NotificationAction{
{ID: "COMPLETE", Title: "Complete"},
{ID: "CANCEL", Title: "Cancel"},
},
})
```
```javascript
await runtime.RegisterNotificationCategory({
id: "TASK_CATEGORY",
actions: [
{id: "COMPLETE", title: "Complete"},
{id: "CANCEL", title: "Cancel"}
]
});
```
### RemoveNotificationCategory
Removes a previously registered notification category.
**Go:** `RemoveNotificationCategory(ctx context.Context, categoryId string) error`
**JavaScript:** `RemoveNotificationCategory(categoryId: string): Promise<void>`
Returns: Error if removal fails
**Example:**
```go
err := runtime.RemoveNotificationCategory(ctx, "TASK_CATEGORY")
```
```javascript
await runtime.RemoveNotificationCategory("TASK_CATEGORY");
```
### RemoveAllPendingNotifications
Removes all pending notifications (macOS and Linux only).
**Go:** `RemoveAllPendingNotifications(ctx context.Context) error`
**JavaScript:** `RemoveAllPendingNotifications(): Promise<void>`
Returns: Error if removal fails
**Example:**
```go
err := runtime.RemoveAllPendingNotifications(ctx)
```
```javascript
await runtime.RemoveAllPendingNotifications();
```
### RemovePendingNotification
Removes a specific pending notification (macOS and Linux only).
**Go:** `RemovePendingNotification(ctx context.Context, identifier string) error`
**JavaScript:** `RemovePendingNotification(identifier: string): Promise<void>`
Returns: Error if removal fails
**Example:**
```go
err := runtime.RemovePendingNotification(ctx, "notif-1")
```
```javascript
await runtime.RemovePendingNotification("notif-1");
```
### RemoveAllDeliveredNotifications
Removes all delivered notifications (macOS and Linux only).
**Go:** `RemoveAllDeliveredNotifications(ctx context.Context) error`
**JavaScript:** `RemoveAllDeliveredNotifications(): Promise<void>`
Returns: Error if removal fails
**Example:**
```go
err := runtime.RemoveAllDeliveredNotifications(ctx)
```
```javascript
await runtime.RemoveAllDeliveredNotifications();
```
### RemoveDeliveredNotification
Removes a specific delivered notification (macOS and Linux only).
**Go:** `RemoveDeliveredNotification(ctx context.Context, identifier string) error`
**JavaScript:** `RemoveDeliveredNotification(identifier: string): Promise<void>`
Returns: Error if removal fails
**Example:**
```go
err := runtime.RemoveDeliveredNotification(ctx, "notif-1")
```
```javascript
await runtime.RemoveDeliveredNotification("notif-1");
```
### RemoveNotification
Removes a notification by identifier (Linux only). On macOS and Windows, this is a stub that always returns `nil`.
**Go:** `RemoveNotification(ctx context.Context, identifier string) error`
**JavaScript:** `RemoveNotification(identifier: string): Promise<void>`
Returns: Error if removal fails
**Example:**
```go
err := runtime.RemoveNotification(ctx, "notif-1")
```
```javascript
await runtime.RemoveNotification("notif-1");
```
### OnNotificationResponse
Registers a callback function to handle notification responses when users interact with notifications.
**Go:** `OnNotificationResponse(ctx context.Context, callback func(result NotificationResult))`
:::note JavaScript
`OnNotificationResponse` is not available in the JavaScript runtime. Instead, JavaScript applications should use the [Events API](/docs/reference/runtime/events) to listen for notification responses. From your Go callback, emit an event that your JavaScript code can listen to.
**Example:**
```go
runtime.OnNotificationResponse(ctx, func(result runtime.NotificationResult) {
if result.Error != nil {
return
}
// Emit an event that JavaScript can listen to
runtime.EventsEmit(ctx, "notification-response", result.Response)
})
```
```javascript
runtime.EventsOn("notification-response", (response) => {
console.log("Notification response:", response);
switch (response.actionIdentifier) {
case "COMPLETE":
// Handle complete action
break;
case "CANCEL":
// Handle cancel action
break;
}
});
```
:::
## Options
### NotificationOptions
**Go:**
```go
type NotificationOptions struct {
ID string `json:"id"`
Title string `json:"title"`
Subtitle string `json:"subtitle,omitempty"` // (macOS and Linux only)
Body string `json:"body,omitempty"`
CategoryID string `json:"categoryId,omitempty"`
Data map[string]interface{} `json:"data,omitempty"`
}
```
**TypeScript:**
```typescript
interface NotificationOptions {
id: string;
title: string;
subtitle?: string; // macOS and Linux only
body?: string;
categoryId?: string;
data?: { [key: string]: any };
}
```
| Field | Description | Win | Mac | Lin |
|-------------|------------------------------------------------|-----|-----|-----|
| ID | Unique identifier for the notification | ✅ | ✅ | ✅ |
| Title | Main notification title | ✅ | ✅ | ✅ |
| Subtitle | Subtitle text (macOS and Linux only) | | ✅ | ✅ |
| Body | Main notification content | ✅ | ✅ | ✅ |
| CategoryID | Category identifier for interactive notifications | ✅ | ✅ | ✅ |
| Data | Custom data to associate with the notification | ✅ | ✅ | ✅ |
### NotificationCategory
**Go:**
```go
type NotificationCategory struct {
ID string `json:"id,omitempty"`
Actions []NotificationAction `json:"actions,omitempty"`
HasReplyField bool `json:"hasReplyField,omitempty"`
ReplyPlaceholder string `json:"replyPlaceholder,omitempty"`
ReplyButtonTitle string `json:"replyButtonTitle,omitempty"`
}
```
**TypeScript:**
```typescript
interface NotificationCategory {
id?: string;
actions?: NotificationAction[];
hasReplyField?: boolean;
replyPlaceholder?: string;
replyButtonTitle?: string;
}
```
| Field | Description | Win | Mac | Lin |
|------------------|------------------------------------------------|-----|-----|-----|
| ID | Unique identifier for the category | ✅ | ✅ | ✅ |
| Actions | Array of action buttons | ✅ | ✅ | ✅ |
| HasReplyField | Whether to include a text input field | ✅ | ✅ | |
| ReplyPlaceholder | Placeholder text for the input field | ✅ | ✅ | |
| ReplyButtonTitle | Text for the reply button | ✅ | ✅ | |
### NotificationAction
**Go:**
```go
type NotificationAction struct {
ID string `json:"id,omitempty"`
Title string `json:"title,omitempty"`
Destructive bool `json:"destructive,omitempty"` // (macOS-specific)
}
```
**TypeScript:**
```typescript
interface NotificationAction {
id?: string;
title?: string;
destructive?: boolean; // macOS-specific
}
```
| Field | Description | Win | Mac | Lin |
|-------------|------------------------------------------------|----------------|-----|-----|
| ID | Unique identifier for the action | ✅ | ✅ | ✅ |
| Title | Button text | ✅ | ✅ | ✅ |
| Destructive | Whether the action is destructive (macOS-only) | | ✅ | |
#### macOS-specific Behavior
On macOS, the `Destructive` flag causes the action button to appear in red, indicating it's a destructive action (like delete or cancel). On Windows and Linux, this flag is ignored.
Example:
```go
actions := []runtime.NotificationAction{
{ID: "SAVE", Title: "Save"},
{ID: "DELETE", Title: "Delete", Destructive: true}, // Shows as red button on macOS
}
```
### NotificationResponse
```go
type NotificationResponse struct {
ID string `json:"id,omitempty"`
ActionIdentifier string `json:"actionIdentifier,omitempty"`
CategoryID string `json:"categoryId,omitempty"` // Consistent with NotificationOptions
Title string `json:"title,omitempty"`
Subtitle string `json:"subtitle,omitempty"` // (macOS and Linux only)
Body string `json:"body,omitempty"`
UserText string `json:"userText,omitempty"`
UserInfo map[string]interface{} `json:"userInfo,omitempty"`
}
```
| Field | Description | Win | Mac | Lin |
|------------------|------------------------------------------------|-----|-----|-----|
| ID | Notification identifier | ✅ | ✅ | ✅ |
| ActionIdentifier | Action that was triggered | ✅ | ✅ | ✅ |
| CategoryID | Category of the notification | ✅ | ✅ | ✅ |
| Title | Title of the notification | ✅ | ✅ | ✅ |
| Subtitle | Subtitle of the notification (macOS and Linux only) | | ✅ | ✅ |
| Body | Body text of the notification | ✅ | ✅ | ✅ |
| UserText | Text entered by the user | ✅ | ✅ | |
| UserInfo | Custom data from the notification | ✅ | ✅ | ✅ |
### NotificationResult
```go
type NotificationResult struct {
Response NotificationResponse
Error error
}
```
| Field | Description |
|----------|------------------------------------------------|
| Response | The notification response data |
| Error | Any error that occurred during the interaction |
## Platform-Specific Behavior
### macOS
- **Authorization Required**: Apps must request notification permission before sending notifications
- **Notarization**: Apps must be notarized for distribution
- **Features**: All features supported including subtitles, text input, and destructive actions
- **Styling**: Automatically adapts to system dark/light mode
- **Center**: Notifications appear in macOS Notification Center
**Example:**
```go
// Check and request authorization
authorized, err := runtime.CheckNotificationAuthorization(ctx)
if err != nil {
return err
}
if !authorized {
authorized, err = runtime.RequestNotificationAuthorization(ctx)
if err != nil || !authorized {
return fmt.Errorf("notification authorization denied")
}
}
// Now send notifications
```
```javascript
// Check and request authorization
let authorized = await runtime.CheckNotificationAuthorization();
if (!authorized) {
authorized = await runtime.RequestNotificationAuthorization();
if (!authorized) {
throw new Error("Notification authorization denied");
}
}
// Now send notifications
```
### Windows
- **No Authorization**: Permission system not required
- **Features**: Supports text input and high DPI displays
- **Limitations**: Subtitle not supported
- **Styling**: Adapts to Windows theme settings
- **Behavior**: Uses Windows toast notification system
### Linux
- **Desktop Environment Dependent**: Behavior varies by DE (GNOME, KDE, XFCE, etc.)
- **Features**: Supports subtitles
- **Limitations**: User text input not supported
- **Styling**: Follows desktop environment theme
- **Behavior**: Uses native notification system when available
**Example:**
```go
// Check system support
if !runtime.IsNotificationAvailable(ctx) {
return fmt.Errorf("notifications not supported on this Linux desktop")
}
// Linux notifications may not support text input
// Only use actions that don't require user text
```
```javascript
// Check system support
const available = await runtime.IsNotificationAvailable();
if (!available) {
throw new Error("Notifications not supported on this Linux desktop");
}
// Linux notifications may not support text input
// Only use actions that don't require user text
```
## Action Identifiers
When handling notification responses, these special action identifiers may be present:
- `DEFAULT_ACTION`: Triggered when the user clicks the notification itself (not an action button)
- `TEXT_REPLY`: Triggered when the user submits text via the reply field
Example response handling:
```go
runtime.OnNotificationResponse(ctx, func(result runtime.NotificationResult) {
if result.Error != nil {
fmt.Printf("Response error: %v\n", result.Error)
return
}
response := result.Response
switch response.ActionIdentifier {
case "DEFAULT_ACTION":
fmt.Println("User clicked the notification")
case "TEXT_REPLY":
fmt.Printf("User replied: %s\n", response.UserText)
case "COMPLETE":
fmt.Println("User clicked Complete button")
case "CANCEL":
fmt.Println("User clicked Cancel button")
}
})
```

View file

@ -40,6 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added `build:tags` to project specification for automatically adding compilation tags by @symball in [PR](https://github.com/wailsapp/wails/pull/4439)
- Support for binding generics in [PR](https://github.dev/wailsapp/wails/pull/3626) by @ktsivkov
- Add universal link support for macOS by @APshenkin in [PR](https://github.com/wailsapp/wails/pull/4693)
- Notifications API in [PR](https://github.com/wailsapp/wails/pull/4256) by @popaprozac
### Fixed
- Added url validation for BrowserOpenURL by @APshenkin in [PR](https://github.com/wailsapp/wails/pull/4484)