mirror of
https://github.com/wailsapp/wails.git
synced 2025-05-04 02:20:47 +08:00
434 lines
12 KiB
Go
434 lines
12 KiB
Go
//go:build windows
|
|
|
|
package notifications
|
|
|
|
import (
|
|
"context"
|
|
_ "embed"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
|
|
"git.sr.ht/~jackmordaunt/go-toast/v2"
|
|
"github.com/google/uuid"
|
|
"github.com/wailsapp/wails/v3/pkg/application"
|
|
"github.com/wailsapp/wails/v3/pkg/w32"
|
|
"golang.org/x/sys/windows/registry"
|
|
)
|
|
|
|
type windowsNotifier struct {
|
|
categories map[string]NotificationCategory
|
|
categoriesLock sync.RWMutex
|
|
appName string
|
|
appGUID string
|
|
iconPath string
|
|
}
|
|
|
|
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 NotificationOptions `json:"payload,omitempty"`
|
|
}
|
|
|
|
// Creates a new Notifications Service.
|
|
func New() *Service {
|
|
notificationServiceOnce.Do(func() {
|
|
impl := &windowsNotifier{
|
|
categories: make(map[string]NotificationCategory),
|
|
}
|
|
|
|
NotificationService = &Service{
|
|
impl: impl,
|
|
}
|
|
})
|
|
|
|
return NotificationService
|
|
}
|
|
|
|
// Startup is called when the service is loaded
|
|
// Sets an activation callback to emit an event when notifications are interacted with.
|
|
func (wn *windowsNotifier) Startup(ctx context.Context, options application.ServiceOptions) error {
|
|
wn.categoriesLock.Lock()
|
|
defer wn.categoriesLock.Unlock()
|
|
|
|
wn.appName = application.Get().Config().Name
|
|
|
|
guid, err := wn.getGUID()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
wn.appGUID = guid
|
|
|
|
wn.iconPath = filepath.Join(os.TempDir(), wn.appName+wn.appGUID+".png")
|
|
|
|
toast.SetAppData(toast.AppData{
|
|
AppID: wn.appName,
|
|
GUID: guid,
|
|
IconPath: wn.iconPath,
|
|
})
|
|
|
|
toast.SetActivationCallback(func(args string, data []toast.UserData) {
|
|
result := NotificationResult{}
|
|
|
|
actionIdentifier, options, err := parseNotificationResponse(args)
|
|
|
|
if err != nil {
|
|
result.Error = err
|
|
|
|
if ns := getNotificationService(); ns != nil {
|
|
ns.handleNotificationResult(result)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Subtitle is retained but was not shown with the notification
|
|
response := NotificationResponse{
|
|
ID: options.ID,
|
|
ActionIdentifier: actionIdentifier,
|
|
Title: options.Title,
|
|
Subtitle: options.Subtitle,
|
|
Body: options.Body,
|
|
CategoryID: options.CategoryID,
|
|
UserInfo: options.Data,
|
|
}
|
|
|
|
if userText, found := wn.getUserText(data); found {
|
|
response.UserText = userText
|
|
}
|
|
|
|
result.Response = response
|
|
if ns := getNotificationService(); ns != nil {
|
|
ns.handleNotificationResult(result)
|
|
}
|
|
})
|
|
|
|
return wn.loadCategoriesFromRegistry()
|
|
}
|
|
|
|
// Shutdown will attempt to save the categories to the registry when the service unloads
|
|
func (wn *windowsNotifier) Shutdown() error {
|
|
wn.categoriesLock.Lock()
|
|
defer wn.categoriesLock.Unlock()
|
|
|
|
return wn.saveCategoriesToRegistry()
|
|
}
|
|
|
|
// RequestNotificationAuthorization is a Windows stub that always returns true, nil.
|
|
// (user authorization is macOS-specific)
|
|
func (wn *windowsNotifier) RequestNotificationAuthorization() (bool, error) {
|
|
return true, nil
|
|
}
|
|
|
|
// CheckNotificationAuthorization is a Windows stub that always returns true.
|
|
// (user authorization is macOS-specific)
|
|
func (wn *windowsNotifier) 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 (wn *windowsNotifier) SendNotification(options NotificationOptions) error {
|
|
if err := wn.saveIconToDir(); err != nil {
|
|
fmt.Printf("Error saving icon: %v\n", err)
|
|
}
|
|
|
|
n := toast.Notification{
|
|
Title: options.Title,
|
|
Body: options.Body,
|
|
ActivationArguments: DefaultActionIdentifier,
|
|
}
|
|
|
|
if options.Data != nil {
|
|
encodedPayload, err := wn.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 (wn *windowsNotifier) SendNotificationWithActions(options NotificationOptions) error {
|
|
if err := wn.saveIconToDir(); err != nil {
|
|
fmt.Printf("Error saving icon: %v\n", err)
|
|
}
|
|
|
|
wn.categoriesLock.RLock()
|
|
nCategory, categoryExists := wn.categories[options.CategoryID]
|
|
wn.categoriesLock.RUnlock()
|
|
|
|
if options.CategoryID == "" || !categoryExists {
|
|
fmt.Printf("Category '%s' not found, sending basic notification without actions\n", options.CategoryID)
|
|
}
|
|
|
|
n := toast.Notification{
|
|
Title: options.Title,
|
|
Body: options.Body,
|
|
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",
|
|
})
|
|
}
|
|
|
|
if options.Data != nil {
|
|
encodedPayload, err := wn.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 := wn.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 (wn *windowsNotifier) RegisterNotificationCategory(category NotificationCategory) error {
|
|
wn.categoriesLock.Lock()
|
|
defer wn.categoriesLock.Unlock()
|
|
|
|
wn.categories[category.ID] = NotificationCategory{
|
|
ID: category.ID,
|
|
Actions: category.Actions,
|
|
HasReplyField: bool(category.HasReplyField),
|
|
ReplyPlaceholder: category.ReplyPlaceholder,
|
|
ReplyButtonTitle: category.ReplyButtonTitle,
|
|
}
|
|
|
|
return wn.saveCategoriesToRegistry()
|
|
}
|
|
|
|
// RemoveNotificationCategory removes a previously registered NotificationCategory.
|
|
func (wn *windowsNotifier) RemoveNotificationCategory(categoryId string) error {
|
|
wn.categoriesLock.Lock()
|
|
defer wn.categoriesLock.Unlock()
|
|
|
|
delete(wn.categories, categoryId)
|
|
|
|
return wn.saveCategoriesToRegistry()
|
|
}
|
|
|
|
// RemoveAllPendingNotifications is a Windows stub that always returns nil.
|
|
// (macOS and Linux only)
|
|
func (wn *windowsNotifier) RemoveAllPendingNotifications() error {
|
|
return nil
|
|
}
|
|
|
|
// RemovePendingNotification is a Windows stub that always returns nil.
|
|
// (macOS and Linux only)
|
|
func (wn *windowsNotifier) RemovePendingNotification(_ string) error {
|
|
return nil
|
|
}
|
|
|
|
// RemoveAllDeliveredNotifications is a Windows stub that always returns nil.
|
|
// (macOS and Linux only)
|
|
func (wn *windowsNotifier) RemoveAllDeliveredNotifications() error {
|
|
return nil
|
|
}
|
|
|
|
// RemoveDeliveredNotification is a Windows stub that always returns nil.
|
|
// (macOS and Linux only)
|
|
func (wn *windowsNotifier) RemoveDeliveredNotification(_ string) error {
|
|
return nil
|
|
}
|
|
|
|
// RemoveNotification is a Windows stub that always returns nil.
|
|
// (Linux-specific)
|
|
func (wn *windowsNotifier) RemoveNotification(identifier string) error {
|
|
return nil
|
|
}
|
|
|
|
// encodePayload combines an action ID and user data into a single encoded string
|
|
func (wn *windowsNotifier) encodePayload(actionID string, options 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, NotificationOptions, error) {
|
|
jsonData, err := base64.StdEncoding.DecodeString(encodedString)
|
|
if err != nil {
|
|
return encodedString, NotificationOptions{}, fmt.Errorf("failed to decode base64 payload: %w", err)
|
|
}
|
|
|
|
var payload NotificationPayload
|
|
if err := json.Unmarshal(jsonData, &payload); err != nil {
|
|
return encodedString, 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 NotificationOptions, err error) {
|
|
actionID, options, err := decodePayload(response)
|
|
|
|
if err != nil {
|
|
fmt.Printf("Warning: Failed to decode notification response: %v\n", err)
|
|
return response, NotificationOptions{}, err
|
|
}
|
|
|
|
return actionID, options, nil
|
|
}
|
|
|
|
func (wn *windowsNotifier) saveIconToDir() error {
|
|
icon, err := application.NewIconFromResource(w32.GetModuleHandle(""), uint16(3))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to retrieve application icon: %w", err)
|
|
}
|
|
|
|
return w32.SaveHIconAsPNG(icon, wn.iconPath)
|
|
}
|
|
|
|
func (wn *windowsNotifier) saveCategoriesToRegistry() error {
|
|
// We assume lock is held by caller
|
|
|
|
registryPath := fmt.Sprintf(NotificationCategoriesRegistryPath, wn.appName)
|
|
|
|
key, _, err := registry.CreateKey(
|
|
registry.CURRENT_USER,
|
|
registryPath,
|
|
registry.ALL_ACCESS,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer key.Close()
|
|
|
|
data, err := json.Marshal(wn.categories)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return key.SetStringValue(NotificationCategoriesRegistryKey, string(data))
|
|
}
|
|
|
|
func (wn *windowsNotifier) loadCategoriesFromRegistry() error {
|
|
// We assume lock is held by caller
|
|
|
|
registryPath := fmt.Sprintf(NotificationCategoriesRegistryPath, wn.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]NotificationCategory)
|
|
if err := json.Unmarshal([]byte(data), &categories); err != nil {
|
|
return fmt.Errorf("failed to parse notification categories from registry: %w", err)
|
|
}
|
|
|
|
wn.categories = categories
|
|
|
|
return nil
|
|
}
|
|
|
|
func (wn *windowsNotifier) getUserText(data []toast.UserData) (string, bool) {
|
|
for _, d := range data {
|
|
if d.Key == "userText" {
|
|
return d.Value, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func (wn *windowsNotifier) getGUID() (string, error) {
|
|
keyPath := ToastRegistryPath + wn.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 := wn.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 (wn *windowsNotifier) generateGUID() string {
|
|
guid := uuid.New()
|
|
return fmt.Sprintf("{%s}", guid.String())
|
|
}
|