mirror of
https://github.com/wailsapp/wails.git
synced 2026-03-14 14:45:49 +01:00
Merge f5fdb378dd into 4d0abeb37c
This commit is contained in:
commit
b9086fa316
24 changed files with 3722 additions and 9 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
465
v2/internal/frontend/desktop/darwin/notifications.go
Normal file
465
v2/internal/frontend/desktop/darwin/notifications.go
Normal 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)
|
||||
}
|
||||
}
|
||||
594
v2/internal/frontend/desktop/linux/notifications.go
Normal file
594
v2/internal/frontend/desktop/linux/notifications.go
Normal 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 := ¬ificationData{
|
||||
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 := ¬ificationData{
|
||||
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)
|
||||
}
|
||||
}
|
||||
489
v2/internal/frontend/desktop/windows/notifications.go
Normal file
489
v2/internal/frontend/desktop/windows/notifications.go
Normal 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())
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
200
v2/internal/frontend/runtime/desktop/notifications.js
Normal file
200
v2/internal/frontend/runtime/desktop/notifications.js
Normal 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
|
|
@ -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>;
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
136
v2/pkg/runtime/notifications.go
Normal file
136
v2/pkg/runtime/notifications.go
Normal 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)
|
||||
}
|
||||
233
website/docs/guides/notifications.mdx
Normal file
233
website/docs/guides/notifications.mdx
Normal 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
|
||||
601
website/docs/reference/runtime/notification.mdx
Normal file
601
website/docs/reference/runtime/notification.mdx
Normal 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")
|
||||
}
|
||||
})
|
||||
```
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue