diff --git a/docs/src/content/docs/changelog.mdx b/docs/src/content/docs/changelog.mdx index 6b9c5f152..681e3715f 100644 --- a/docs/src/content/docs/changelog.mdx +++ b/docs/src/content/docs/changelog.mdx @@ -76,6 +76,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add cancellation support for query methods on `sqlite` service by [@fbbdev](https://github.com/fbbdev) in [#4067](https://github.com/wailsapp/wails/pull/4067) - Add prepared statement support to `sqlite` service with JS bindings by [@fbbdev](https://github.com/fbbdev) in [#4067](https://github.com/wailsapp/wails/pull/4067) - Add `SetMenu()` on window to allow for setting a menu on a window by [@leaanthony](https://github.com/leaanthony) +- Add Notification support by [@popaprozac] in [#4098](https://github.com/wailsapp/wails/pull/4098) -  Add File Association support for mac by [@wimaha](https://github.com/wimaha) in [#4177](https://github.com/wailsapp/wails/pull/4177) ### Fixed diff --git a/docs/src/content/docs/learn/notifications.mdx b/docs/src/content/docs/learn/notifications.mdx new file mode 100644 index 000000000..5a59f5e2a --- /dev/null +++ b/docs/src/content/docs/learn/notifications.mdx @@ -0,0 +1,304 @@ +--- +title: Notifications +--- + +import { Tabs, TabItem } from "@astrojs/starlight/components"; + +## Introduction + +Wails provides a comprehensive cross-platform notification system for desktop applications. This service allows you to display native system notifications, with support for interactive elements like action buttons and text input fields. + +## Basic Usage + +### Creating the Service + +First, initialize the notifications service: + +```go +import "github.com/wailsapp/wails/v3/pkg/application" +import "github.com/wailsapp/wails/v3/services/notifications" + +// Create a new notification service +notifier := notifications.New() + +//Register the service with the application +app := application.New(application.Options{ + Services: []application.Service{ + application.NewService(notifier), + }, +}) +``` + +## Notification Authorization + +Notifications on macOS require user authorization. Request and check authorization: + +```go +authorized, err := notifier.CheckNotificationAuthorization() +if err != nil { + // Handle authorization error +} +if authorized { + // Send notifications +} else { + // Request authorization + authorized, err = notifier.RequestNotificationAuthorization() +} +``` +On Windows and Linux this always returns `true`. + +## Notification Types + +### Basic Notifications + +Send a basic notification with a unique id, title, optional subtitle (macOS and Linux), and body text to users: + +```go +notifier.SendNotification(notifications.NotificationOptions{ + ID: "unique-id", + Title: "New Calendar Invite", + Subtitle: "From: Jane Doe", // Optional + Body: "Tap to view the event", +}) + +``` + +### Interactive Notifications +Send a notification with action buttons and text inputs. These notifications require a notification category to be resgistered first: + +```go +// Define a unique category id +categoryID := "unique-category-id" + +// Define a category with actions +category := notifications.NotificationCategory{ + ID: categoryID, + Actions: []notifications.NotificationAction{ + { + ID: "OPEN", + Title: "Open", + }, + { + ID: "ARCHIVE", + Title: "Archive", + Destructive: true, /* macOS-specific */ + }, + }, + HasReplyField: true, + ReplyPlaceholder: "message...", + ReplyButtonTitle: "Reply", +} + +// Register the category +notifier.RegisterNotificationCategory(category) + +// Send an interactive notification with the actions registered in the provided category +notifier.SendNotificationWithActions(notifications.NotificationOptions{ + ID: "unique-id", + Title: "New Message", + Subtitle: "From: Jane Doe", + Body: "Are you able to make it?", + CategoryID: categoryID, +}) +``` + +## Notification Responses + +Process user interactions with notifications: + +```go +notifier.OnNotificationResponse(func(result notifications.NotificationResult) { + 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) + } + + if data, ok := response.UserInfo["sender"].(string); ok { + fmt.Printf("Original sender: %s\n", data) + } + + // Emit an event to the frontend + app.EmitEvent("notification", result.Response) +}) +``` + +## Notification Customisation + +### Custom Metadata + +Basic and interactive notifications can include custom data: + +```go +notifier.SendNotification(notifications.NotificationOptions{ + ID: "unique-id", + Title: "New Calendar Invite", + Subtitle: "From: Jane Doe", // Optional + Body: "Tap to view the event", + Data: map[string]interface{}{ + "sender": "jane.doe@example.com", + "timestamp": "2025-03-10T15:30:00Z", + } +}) + +``` + +## Platform Considerations + + + + + On macOS, notifications: + + - Require user authorization + - Require the app to be notorized for distribution + - Use system-standard notification appearances + - Support `subtitle` + - Support user text input + - Support the `Destructive` action option + - Automatically handle dark/light mode + + + + + + On Windows, notifications: + + - Use Windows system toast styles + - Adapt to Windows theme settings + - Support user text input + - Support high DPI displays + - Do not support `subtitle` + + + + + + On Linux, dialog behaviour depends on the desktop environment: + + - Use native notifications when available + - Follow desktop environment theme + - Position according to desktop environment rules + - Support `subtitle` + - Do not support user text input + + + + +## Best Practices + +1. Check and request for authorization: + - macOS requires user authorization + +2. Provide clear and concise notifications: + - Use descriptive titles, subtitles, text, and action titles + +3. Handle dialog responses appropriately: + - Check for errors in notification responses + - Provide feedback for user actions + +4. Consider platform conventions: + - Follow platform-specific notification patterns + - Respect system settings + +## Examples + +Explore this example: + +- [Notifications](/examples/notifications) + +## API Reference + +### Service Management +| Method | Description | +|--------------------------------------------|-------------------------------------------------------| +| `New()` | Creates a new notifications service | + +### Notification Authorization +| Method | Description | +|----------------------------------------------|------------------------------------------------------------| +| `RequestNotificationAuthorization()` | Requests permission to display notifications (macOS) | +| `CheckNotificationAuthorization()` | Checks current notification authorization status (macOS) | + +### Sending Notifications +| Method | Description | +|------------------------------------------------------------|---------------------------------------------------| +| `SendNotification(options NotificationOptions)` | Sends a basic notification | +| `SendNotificationWithActions(options NotificationOptions)` | Sends an interactive notification with actions | + +### Notification Categories +| Method | Description | +|---------------------------------------------------------------|---------------------------------------------------| +| `RegisterNotificationCategory(category NotificationCategory)` | Registers a reusable notification category | +| `RemoveNotificationCategory(categoryID string)` | Removes a previously registered category | + +### Managing Notifications +| Method | Description | +|-------------------------------------------------|---------------------------------------------------------------------| +| `RemoveAllPendingNotifications()` | Removes all pending notifications (macOS and Linux only) | +| `RemovePendingNotification(identifier string)` | Removes a specific pending notification (macOS and Linux only) | +| `RemoveAllDeliveredNotifications()` | Removes all delivered notifications (macOS and Linux only) | +| `RemoveDeliveredNotification(identifier string)`| Removes a specific delivered notification (macOS and Linux only) | +| `RemoveNotification(identifier string)` | Removes a notification (Linux-specific) | + +### Event Handling +| Method | Description | +|--------------------------------------------------------------------|-------------------------------------------------| +| `OnNotificationResponse(callback func(result NotificationResult))` | Registers a callback for notification responses | + +### Structs and Types + +#### NotificationOptions +```go +type NotificationOptions struct { + ID string // Unique identifier for the notification + Title string // Main notification title + Subtitle string // Subtitle text (macOS and Linux only) + Body string // Main notification content + CategoryID string // Category identifier for interactive notifications + Data map[string]interface{} // Custom data to associate with the notification +} +``` + +#### NotificationCategory +```go +type NotificationCategory struct { + ID string // Unique identifier for the category + Actions []NotificationAction // Button actions for the notification + HasReplyField bool // Whether to include a text input field + ReplyPlaceholder string // Placeholder text for the input field + ReplyButtonTitle string // Text for the reply button +} +``` + +#### NotificationAction +```go +type NotificationAction struct { + ID string // Unique identifier for the action + Title string // Button text + Destructive bool // Whether the action is destructive (macOS-specific) +} +``` + +#### NotificationResponse +```go +type NotificationResponse struct { + ID string // Notification identifier + ActionIdentifier string // Action that was triggered + CategoryID string // Category of the notification + Title string // Title of the notification + Subtitle string // Subtitle of the notification + Body string // Body text of the notification + UserText string // Text entered by the user + UserInfo map[string]interface{} // Custom data from the notification +} +``` + +#### NotificationResult +```go +type NotificationResult struct { + Response NotificationResponse // Response data + Error error // Any error that occurred +} +``` \ No newline at end of file diff --git a/v3/examples/notifications/README.md b/v3/examples/notifications/README.md new file mode 100644 index 000000000..ad12c3f40 --- /dev/null +++ b/v3/examples/notifications/README.md @@ -0,0 +1,59 @@ +# Welcome to Your New Wails3 Project! + +Congratulations on generating your Wails3 application! This README will guide you through the next steps to get your project up and running. + +## Getting Started + +1. Navigate to your project directory in the terminal. + +2. To run your application in development mode, use the following command: + + ``` + wails3 dev + ``` + + This will start your application and enable hot-reloading for both frontend and backend changes. + +3. To build your application for production, use: + + ``` + wails3 build + ``` + + This will create a production-ready executable in the `build` directory. + +## Exploring Wails3 Features + +Now that you have your project set up, it's time to explore the features that Wails3 offers: + +1. **Check out the examples**: The best way to learn is by example. Visit the `examples` directory in the `v3/examples` directory to see various sample applications. + +2. **Run an example**: To run any of the examples, navigate to the example's directory and use: + + ``` + go run . + ``` + + Note: Some examples may be under development during the alpha phase. + +3. **Explore the documentation**: Visit the [Wails3 documentation](https://v3.wails.io/) for in-depth guides and API references. + +4. **Join the community**: Have questions or want to share your progress? Join the [Wails Discord](https://discord.gg/JDdSxwjhGf) or visit the [Wails discussions on GitHub](https://github.com/wailsapp/wails/discussions). + +## Project Structure + +Take a moment to familiarize yourself with your project structure: + +- `frontend/`: Contains your frontend code (HTML, CSS, JavaScript/TypeScript) +- `main.go`: The entry point of your Go backend +- `app.go`: Define your application structure and methods here +- `wails.json`: Configuration file for your Wails project + +## Next Steps + +1. Modify the frontend in the `frontend/` directory to create your desired UI. +2. Add backend functionality in `main.go`. +3. Use `wails3 dev` to see your changes in real-time. +4. When ready, build your application with `wails3 build`. + +Happy coding with Wails3! If you encounter any issues or have questions, don't hesitate to consult the documentation or reach out to the Wails community. diff --git a/v3/examples/notifications/Taskfile.yml b/v3/examples/notifications/Taskfile.yml new file mode 100644 index 000000000..1455cd70c --- /dev/null +++ b/v3/examples/notifications/Taskfile.yml @@ -0,0 +1,34 @@ +version: '3' + +includes: + common: ./build/Taskfile.yml + windows: ./build/windows/Taskfile.yml + darwin: ./build/darwin/Taskfile.yml + linux: ./build/linux/Taskfile.yml + +vars: + APP_NAME: "Notifications\\ Demo" + BIN_DIR: "bin" + VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}' + +tasks: + build: + summary: Builds the application + cmds: + - task: "{{OS}}:build" + + package: + summary: Packages a production build of the application + cmds: + - task: "{{OS}}:package" + + run: + summary: Runs the application + cmds: + - task: "{{OS}}:run" + + dev: + summary: Runs the application in development mode + cmds: + - wails3 dev -config ./build/config.yml -port {{.VITE_PORT}} + diff --git a/v3/examples/notifications/build/Taskfile.yml b/v3/examples/notifications/build/Taskfile.yml new file mode 100644 index 000000000..5f3517efc --- /dev/null +++ b/v3/examples/notifications/build/Taskfile.yml @@ -0,0 +1,86 @@ +version: '3' + +tasks: + go:mod:tidy: + summary: Runs `go mod tidy` + internal: true + cmds: + - go mod tidy + + install:frontend:deps: + summary: Install frontend dependencies + dir: frontend + sources: + - package.json + - package-lock.json + generates: + - node_modules/* + preconditions: + - sh: npm version + msg: "Looks like npm isn't installed. Npm is part of the Node installer: https://nodejs.org/en/download/" + cmds: + - npm install + + build:frontend: + label: build:frontend (PRODUCTION={{.PRODUCTION}}) + summary: Build the frontend project + dir: frontend + sources: + - "**/*" + generates: + - dist/**/* + deps: + - task: install:frontend:deps + - task: generate:bindings + vars: + BUILD_FLAGS: + ref: .BUILD_FLAGS + cmds: + - npm run {{.BUILD_COMMAND}} -q + env: + PRODUCTION: '{{.PRODUCTION | default "false"}}' + vars: + BUILD_COMMAND: '{{if eq .PRODUCTION "true"}}build{{else}}build:dev{{end}}' + + + generate:bindings: + label: generate:bindings (BUILD_FLAGS={{.BUILD_FLAGS}}) + summary: Generates bindings for the frontend + deps: + - task: go:mod:tidy + sources: + - "**/*.[jt]s" + - exclude: frontend/**/* + - frontend/bindings/**/* # Rerun when switching between dev/production mode causes changes in output + - "**/*.go" + - go.mod + - go.sum + generates: + - frontend/bindings/**/* + cmds: + - wails3 generate bindings -f '{{.BUILD_FLAGS}}' -clean=true -ts + + generate:icons: + summary: Generates Windows `.ico` and Mac `.icns` files from an image + dir: build + sources: + - "appicon.png" + generates: + - "darwin/icons.icns" + - "windows/icon.ico" + cmds: + - wails3 generate icons -input appicon.png -macfilename darwin/icons.icns -windowsfilename windows/icon.ico + + dev:frontend: + summary: Runs the frontend in development mode + dir: frontend + deps: + - task: install:frontend:deps + cmds: + - npm run dev -- --port {{.VITE_PORT}} --strictPort + + update:build-assets: + summary: Updates the build assets + dir: build + cmds: + - wails3 update build-assets -name "{{.APP_NAME}}" -binaryname "{{.APP_NAME}}" -config config.yml -dir . diff --git a/v3/examples/notifications/build/appicon.png b/v3/examples/notifications/build/appicon.png new file mode 100644 index 000000000..63617fe4f Binary files /dev/null and b/v3/examples/notifications/build/appicon.png differ diff --git a/v3/examples/notifications/build/config.yml b/v3/examples/notifications/build/config.yml new file mode 100644 index 000000000..bc09a6d28 --- /dev/null +++ b/v3/examples/notifications/build/config.yml @@ -0,0 +1,62 @@ +# This file contains the configuration for this project. +# When you update `info` or `fileAssociations`, run `wails3 task common:update:build-assets` to update the assets. +# Note that this will overwrite any changes you have made to the assets. +version: '3' + +# This information is used to generate the build assets. +info: + companyName: "My Company" # The name of the company + productName: "My Product" # The name of the application + productIdentifier: "com.mycompany.myproduct" # The unique product identifier + description: "A program that does X" # The application description + copyright: "(c) 2025, My Company" # Copyright text + comments: "Some Product Comments" # Comments + version: "v0.0.1" # The application version + +# Dev mode configuration +dev_mode: + root_path: . + log_level: warn + debounce: 1000 + ignore: + dir: + - .git + - node_modules + - frontend + - bin + file: + - .DS_Store + - .gitignore + - .gitkeep + watched_extension: + - "*.go" + git_ignore: true + executes: + - cmd: wails3 task common:install:frontend:deps + type: once + - cmd: wails3 task common:dev:frontend + type: background + - cmd: go mod tidy + type: blocking + - cmd: wails3 task build + type: blocking + - cmd: wails3 task run + type: primary + +# File Associations +# More information at: https://v3.wails.io/noit/done/yet +fileAssociations: +# - ext: wails +# name: Wails +# description: Wails Application File +# iconName: wailsFileIcon +# role: Editor +# - ext: jpg +# name: JPEG +# description: Image File +# iconName: jpegFileIcon +# role: Editor + +# Other data +other: + - name: My Other Data \ No newline at end of file diff --git a/v3/examples/notifications/build/darwin/Info.dev.plist b/v3/examples/notifications/build/darwin/Info.dev.plist new file mode 100644 index 000000000..3a5b9735f --- /dev/null +++ b/v3/examples/notifications/build/darwin/Info.dev.plist @@ -0,0 +1,32 @@ + + + + CFBundlePackageType + APPL + CFBundleName + My Product + CFBundleExecutable + Notifications Demo + CFBundleIdentifier + com.wails.notifications-demo + CFBundleVersion + 0.1.0 + CFBundleGetInfoString + This is a comment + CFBundleShortVersionString + 0.1.0 + CFBundleIconFile + icons + LSMinimumSystemVersion + 10.15.0 + NSHighResolutionCapable + true + NSHumanReadableCopyright + © now, My Company + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + + \ No newline at end of file diff --git a/v3/examples/notifications/build/darwin/Info.plist b/v3/examples/notifications/build/darwin/Info.plist new file mode 100644 index 000000000..464270019 --- /dev/null +++ b/v3/examples/notifications/build/darwin/Info.plist @@ -0,0 +1,27 @@ + + + + CFBundlePackageType + APPL + CFBundleName + My Product + CFBundleExecutable + Notifications Demo + CFBundleIdentifier + com.wails.notifications-demo + CFBundleVersion + 0.1.0 + CFBundleGetInfoString + This is a comment + CFBundleShortVersionString + 0.1.0 + CFBundleIconFile + icons + LSMinimumSystemVersion + 10.15.0 + NSHighResolutionCapable + true + NSHumanReadableCopyright + © now, My Company + + \ No newline at end of file diff --git a/v3/examples/notifications/build/darwin/Taskfile.yml b/v3/examples/notifications/build/darwin/Taskfile.yml new file mode 100644 index 000000000..3b6a9dc99 --- /dev/null +++ b/v3/examples/notifications/build/darwin/Taskfile.yml @@ -0,0 +1,80 @@ +version: '3' + +includes: + common: ../Taskfile.yml + +tasks: + build: + summary: Creates a production build of the application + deps: + - task: common:go:mod:tidy + - task: common:build:frontend + vars: + BUILD_FLAGS: + ref: .BUILD_FLAGS + PRODUCTION: + ref: .PRODUCTION + - task: common:generate:icons + cmds: + - go build {{.BUILD_FLAGS}} -o {{.OUTPUT}} + vars: + BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -buildvcs=false -ldflags="-w -s"{{else}}-buildvcs=false -gcflags=all="-l"{{end}}' + DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}' + OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}' + env: + GOOS: darwin + CGO_ENABLED: 1 + GOARCH: '{{.ARCH | default ARCH}}' + CGO_CFLAGS: "-mmacosx-version-min=10.15" + CGO_LDFLAGS: "-mmacosx-version-min=10.15" + MACOSX_DEPLOYMENT_TARGET: "10.15" + PRODUCTION: '{{.PRODUCTION | default "false"}}' + + build:universal: + summary: Builds darwin universal binary (arm64 + amd64) + deps: + - task: build + vars: + ARCH: amd64 + OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" + - task: build + vars: + ARCH: arm64 + OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-arm64" + cmds: + - lipo -create -output "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64" + - rm "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64" + + package: + summary: Packages a production build of the application into a `.app` bundle + deps: + - task: build + vars: + PRODUCTION: "true" + cmds: + - task: create:app:bundle + + package:universal: + summary: Packages darwin universal binary (arm64 + amd64) + deps: + - task: build:universal + cmds: + - task: create:app:bundle + + + create:app:bundle: + summary: Creates an `.app` bundle + cmds: + - mkdir -p {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/{MacOS,Resources} + - cp build/darwin/icons.icns {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources + - cp {{.BIN_DIR}}/{{.APP_NAME}} {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS + - cp build/darwin/Info.plist {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents + + run: + cmds: + - mkdir -p {{.BIN_DIR}}/dev/{{.APP_NAME}}.app/Contents/{MacOS,Resources} + - cp build/darwin/icons.icns {{.BIN_DIR}}/dev/{{.APP_NAME}}.app/Contents/Resources + - cp {{.BIN_DIR}}/{{.APP_NAME}} {{.BIN_DIR}}/dev/{{.APP_NAME}}.app/Contents/MacOS + - cp build/darwin/Info.dev.plist {{.BIN_DIR}}/dev/{{.APP_NAME}}.app/Contents/Info.plist + - codesign --force --deep --sign - {{.BIN_DIR}}/dev/{{.APP_NAME}}.app + - '{{.BIN_DIR}}/dev/{{.APP_NAME}}.app/Contents/MacOS/{{.APP_NAME}}' diff --git a/v3/examples/notifications/build/darwin/icons.icns b/v3/examples/notifications/build/darwin/icons.icns new file mode 100644 index 000000000..1b5bd4c86 Binary files /dev/null and b/v3/examples/notifications/build/darwin/icons.icns differ diff --git a/v3/examples/notifications/build/linux/Taskfile.yml b/v3/examples/notifications/build/linux/Taskfile.yml new file mode 100644 index 000000000..560cc9c92 --- /dev/null +++ b/v3/examples/notifications/build/linux/Taskfile.yml @@ -0,0 +1,119 @@ +version: '3' + +includes: + common: ../Taskfile.yml + +tasks: + build: + summary: Builds the application for Linux + deps: + - task: common:go:mod:tidy + - task: common:build:frontend + vars: + BUILD_FLAGS: + ref: .BUILD_FLAGS + PRODUCTION: + ref: .PRODUCTION + - task: common:generate:icons + cmds: + - go build {{.BUILD_FLAGS}} -o {{.BIN_DIR}}/{{.APP_NAME}} + vars: + BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -buildvcs=false -ldflags="-w -s"{{else}}-buildvcs=false -gcflags=all="-l"{{end}}' + env: + GOOS: linux + CGO_ENABLED: 1 + GOARCH: '{{.ARCH | default ARCH}}' + PRODUCTION: '{{.PRODUCTION | default "false"}}' + + package: + summary: Packages a production build of the application for Linux + deps: + - task: build + vars: + PRODUCTION: "true" + cmds: + - task: create:appimage + - task: create:deb + - task: create:rpm + - task: create:aur + + create:appimage: + summary: Creates an AppImage + dir: build/linux/appimage + deps: + - task: build + vars: + PRODUCTION: "true" + - task: generate:dotdesktop + cmds: + - cp {{.APP_BINARY}} {{.APP_NAME}} + - cp ../../appicon.png appicon.png + - wails3 generate appimage -binary {{.APP_NAME}} -icon {{.ICON}} -desktopfile {{.DESKTOP_FILE}} -outputdir {{.OUTPUT_DIR}} -builddir {{.ROOT_DIR}}/build/linux/appimage/build + vars: + APP_NAME: '{{.APP_NAME}}' + APP_BINARY: '../../../bin/{{.APP_NAME}}' + ICON: '../../appicon.png' + DESKTOP_FILE: '../{{.APP_NAME}}.desktop' + OUTPUT_DIR: '../../../bin' + + create:deb: + summary: Creates a deb package + deps: + - task: build + vars: + PRODUCTION: "true" + cmds: + - task: generate:dotdesktop + - task: generate:deb + + create:rpm: + summary: Creates a rpm package + deps: + - task: build + vars: + PRODUCTION: "true" + cmds: + - task: generate:dotdesktop + - task: generate:rpm + + create:aur: + summary: Creates a arch linux packager package + deps: + - task: build + vars: + PRODUCTION: "true" + cmds: + - task: generate:dotdesktop + - task: generate:aur + + generate:deb: + summary: Creates a deb package + cmds: + - wails3 tool package -name {{.APP_NAME}} -format deb -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin + + generate:rpm: + summary: Creates a rpm package + cmds: + - wails3 tool package -name {{.APP_NAME}} -format rpm -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin + + generate:aur: + summary: Creates a arch linux packager package + cmds: + - wails3 tool package -name {{.APP_NAME}} -format archlinux -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin + + generate:dotdesktop: + summary: Generates a `.desktop` file + dir: build + cmds: + - mkdir -p {{.ROOT_DIR}}/build/linux/appimage + - wails3 generate .desktop -name "{{.APP_NAME}}" -exec "{{.EXEC}}" -icon "{{.ICON}}" -outputfile {{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop -categories "{{.CATEGORIES}}" + vars: + APP_NAME: '{{.APP_NAME}}' + EXEC: '{{.APP_NAME}}' + ICON: 'appicon' + CATEGORIES: 'Development;' + OUTPUTFILE: '{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop' + + run: + cmds: + - '{{.BIN_DIR}}/{{.APP_NAME}}' diff --git a/v3/examples/notifications/build/linux/appimage/build.sh b/v3/examples/notifications/build/linux/appimage/build.sh new file mode 100644 index 000000000..85901c34e --- /dev/null +++ b/v3/examples/notifications/build/linux/appimage/build.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Copyright (c) 2018-Present Lea Anthony +# SPDX-License-Identifier: MIT + +# Fail script on any error +set -euxo pipefail + +# Define variables +APP_DIR="${APP_NAME}.AppDir" + +# Create AppDir structure +mkdir -p "${APP_DIR}/usr/bin" +cp -r "${APP_BINARY}" "${APP_DIR}/usr/bin/" +cp "${ICON_PATH}" "${APP_DIR}/" +cp "${DESKTOP_FILE}" "${APP_DIR}/" + +if [[ $(uname -m) == *x86_64* ]]; then + # Download linuxdeploy and make it executable + wget -q -4 -N https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage + chmod +x linuxdeploy-x86_64.AppImage + + # Run linuxdeploy to bundle the application + ./linuxdeploy-x86_64.AppImage --appdir "${APP_DIR}" --output appimage +else + # Download linuxdeploy and make it executable (arm64) + wget -q -4 -N https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-aarch64.AppImage + chmod +x linuxdeploy-aarch64.AppImage + + # Run linuxdeploy to bundle the application (arm64) + ./linuxdeploy-aarch64.AppImage --appdir "${APP_DIR}" --output appimage +fi + +# Rename the generated AppImage +mv "${APP_NAME}*.AppImage" "${APP_NAME}.AppImage" + diff --git a/v3/examples/notifications/build/linux/nfpm/nfpm.yaml b/v3/examples/notifications/build/linux/nfpm/nfpm.yaml new file mode 100644 index 000000000..c2cb7cd81 --- /dev/null +++ b/v3/examples/notifications/build/linux/nfpm/nfpm.yaml @@ -0,0 +1,50 @@ +# Feel free to remove those if you don't want/need to use them. +# Make sure to check the documentation at https://nfpm.goreleaser.com +# +# The lines below are called `modelines`. See `:help modeline` + +name: "notifications" +arch: ${GOARCH} +platform: "linux" +version: "0.1.0" +section: "default" +priority: "extra" +maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}> +description: "My Product Description" +vendor: "My Company" +homepage: "https://wails.io" +license: "MIT" +release: "1" + +contents: + - src: "./bin/notifications" + dst: "/usr/local/bin/notifications" + - src: "./build/appicon.png" + dst: "/usr/share/icons/hicolor/128x128/apps/notifications.png" + - src: "./build/linux/notifications.desktop" + dst: "/usr/share/applications/notifications.desktop" + +depends: + - gtk3 + - libwebkit2gtk + +# replaces: +# - foobar +# provides: +# - bar +# depends: +# - gtk3 +# - libwebkit2gtk +# recommends: +# - whatever +# suggests: +# - something-else +# conflicts: +# - not-foo +# - not-bar +# changelog: "changelog.yaml" +# scripts: +# preinstall: ./build/linux/nfpm/scripts/preinstall.sh +# postinstall: ./build/linux/nfpm/scripts/postinstall.sh +# preremove: ./build/linux/nfpm/scripts/preremove.sh +# postremove: ./build/linux/nfpm/scripts/postremove.sh diff --git a/v3/examples/notifications/build/linux/nfpm/scripts/postinstall.sh b/v3/examples/notifications/build/linux/nfpm/scripts/postinstall.sh new file mode 100644 index 000000000..a9bf588e2 --- /dev/null +++ b/v3/examples/notifications/build/linux/nfpm/scripts/postinstall.sh @@ -0,0 +1 @@ +#!/bin/bash diff --git a/v3/examples/notifications/build/linux/nfpm/scripts/postremove.sh b/v3/examples/notifications/build/linux/nfpm/scripts/postremove.sh new file mode 100644 index 000000000..a9bf588e2 --- /dev/null +++ b/v3/examples/notifications/build/linux/nfpm/scripts/postremove.sh @@ -0,0 +1 @@ +#!/bin/bash diff --git a/v3/examples/notifications/build/linux/nfpm/scripts/preinstall.sh b/v3/examples/notifications/build/linux/nfpm/scripts/preinstall.sh new file mode 100644 index 000000000..a9bf588e2 --- /dev/null +++ b/v3/examples/notifications/build/linux/nfpm/scripts/preinstall.sh @@ -0,0 +1 @@ +#!/bin/bash diff --git a/v3/examples/notifications/build/linux/nfpm/scripts/preremove.sh b/v3/examples/notifications/build/linux/nfpm/scripts/preremove.sh new file mode 100644 index 000000000..a9bf588e2 --- /dev/null +++ b/v3/examples/notifications/build/linux/nfpm/scripts/preremove.sh @@ -0,0 +1 @@ +#!/bin/bash diff --git a/v3/examples/notifications/build/windows/Taskfile.yml b/v3/examples/notifications/build/windows/Taskfile.yml new file mode 100644 index 000000000..be6e4125e --- /dev/null +++ b/v3/examples/notifications/build/windows/Taskfile.yml @@ -0,0 +1,63 @@ +version: '3' + +includes: + common: ../Taskfile.yml + +tasks: + build: + summary: Builds the application for Windows + deps: + - task: common:go:mod:tidy + - task: common:build:frontend + vars: + BUILD_FLAGS: + ref: .BUILD_FLAGS + PRODUCTION: + ref: .PRODUCTION + - task: common:generate:icons + cmds: + - task: generate:syso + - go build {{.BUILD_FLAGS}} -o {{.BIN_DIR}}/{{.APP_NAME}}.exe + - cmd: powershell Remove-item *.syso + platforms: [windows] + - cmd: rm -f *.syso + platforms: [linux, darwin] + vars: + BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -buildvcs=false -ldflags="-w -s -H windowsgui"{{else}}-buildvcs=false -gcflags=all="-l"{{end}}' + env: + GOOS: windows + CGO_ENABLED: 0 + GOARCH: '{{.ARCH | default ARCH}}' + PRODUCTION: '{{.PRODUCTION | default "false"}}' + + package: + summary: Packages a production build of the application into a `.exe` bundle + cmds: + - task: create:nsis:installer + + generate:syso: + summary: Generates Windows `.syso` file + dir: build + cmds: + - wails3 generate syso -arch {{.ARCH}} -icon windows/icon.ico -manifest windows/wails.exe.manifest -info windows/info.json -out ../wails_windows_{{.ARCH}}.syso + vars: + ARCH: '{{.ARCH | default ARCH}}' + + create:nsis:installer: + summary: Creates an NSIS installer + dir: build/windows/nsis + deps: + - task: build + vars: + PRODUCTION: "true" + cmds: + # Create the Microsoft WebView2 bootstrapper if it doesn't exist + - wails3 generate webview2bootstrapper -dir "{{.ROOT_DIR}}/build/windows/nsis" + - makensis -DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" project.nsi + vars: + ARCH: '{{.ARCH | default ARCH}}' + ARG_FLAG: '{{if eq .ARCH "amd64"}}AMD64{{else}}ARM64{{end}}' + + run: + cmds: + - '{{.BIN_DIR}}\\{{.APP_NAME}}.exe' diff --git a/v3/examples/notifications/build/windows/icon.ico b/v3/examples/notifications/build/windows/icon.ico new file mode 100644 index 000000000..bfa0690b7 Binary files /dev/null and b/v3/examples/notifications/build/windows/icon.ico differ diff --git a/v3/examples/notifications/build/windows/info.json b/v3/examples/notifications/build/windows/info.json new file mode 100644 index 000000000..850b2b5b0 --- /dev/null +++ b/v3/examples/notifications/build/windows/info.json @@ -0,0 +1,15 @@ +{ + "fixed": { + "file_version": "0.1.0" + }, + "info": { + "0000": { + "ProductVersion": "0.1.0", + "CompanyName": "My Company", + "FileDescription": "My Product Description", + "LegalCopyright": "© now, My Company", + "ProductName": "My Product", + "Comments": "This is a comment" + } + } +} \ No newline at end of file diff --git a/v3/examples/notifications/build/windows/nsis/project.nsi b/v3/examples/notifications/build/windows/nsis/project.nsi new file mode 100644 index 000000000..4cb18e04f --- /dev/null +++ b/v3/examples/notifications/build/windows/nsis/project.nsi @@ -0,0 +1,112 @@ +Unicode true + +#### +## Please note: Template replacements don't work in this file. They are provided with default defines like +## mentioned underneath. +## If the keyword is not defined, "wails_tools.nsh" will populate them. +## If they are defined here, "wails_tools.nsh" will not touch them. This allows you to use this project.nsi manually +## from outside of Wails for debugging and development of the installer. +## +## For development first make a wails nsis build to populate the "wails_tools.nsh": +## > wails build --target windows/amd64 --nsis +## Then you can call makensis on this file with specifying the path to your binary: +## For a AMD64 only installer: +## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe +## For a ARM64 only installer: +## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe +## For a installer with both architectures: +## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe +#### +## The following information is taken from the wails_tools.nsh file, but they can be overwritten here. +#### +## !define INFO_PROJECTNAME "my-project" # Default "notifications" +## !define INFO_COMPANYNAME "My Company" # Default "My Company" +## !define INFO_PRODUCTNAME "My Product Name" # Default "My Product" +## !define INFO_PRODUCTVERSION "1.0.0" # Default "0.1.0" +## !define INFO_COPYRIGHT "(c) Now, My Company" # Default "© now, My Company" +### +## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe" +## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}" +#### +## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html +#### +## Include the wails tools +#### +!include "wails_tools.nsh" + +# The version information for this two must consist of 4 parts +VIProductVersion "${INFO_PRODUCTVERSION}.0" +VIFileVersion "${INFO_PRODUCTVERSION}.0" + +VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}" +VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer" +VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}" +VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}" +VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}" +VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}" + +# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware +ManifestDPIAware true + +!include "MUI.nsh" + +!define MUI_ICON "..\icon.ico" +!define MUI_UNICON "..\icon.ico" +# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314 +!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps +!define MUI_ABORTWARNING # This will warn the user if they exit from the installer. + +!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page. +# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer +!insertmacro MUI_PAGE_DIRECTORY # In which folder install page. +!insertmacro MUI_PAGE_INSTFILES # Installing page. +!insertmacro MUI_PAGE_FINISH # Finished installation page. + +!insertmacro MUI_UNPAGE_INSTFILES # Uninstalling page + +!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer + +## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1 +#!uninstfinalize 'signtool --file "%1"' +#!finalize 'signtool --file "%1"' + +Name "${INFO_PRODUCTNAME}" +OutFile "..\..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file. +InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" # Default installing folder ($PROGRAMFILES is Program Files folder). +ShowInstDetails show # This will always show the installation details. + +Function .onInit + !insertmacro wails.checkArchitecture +FunctionEnd + +Section + !insertmacro wails.setShellContext + + !insertmacro wails.webview2runtime + + SetOutPath $INSTDIR + + !insertmacro wails.files + + CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}" + CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}" + + !insertmacro wails.associateFiles + + !insertmacro wails.writeUninstaller +SectionEnd + +Section "uninstall" + !insertmacro wails.setShellContext + + RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath + + RMDir /r $INSTDIR + + Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" + Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk" + + !insertmacro wails.unassociateFiles + + !insertmacro wails.deleteUninstaller +SectionEnd diff --git a/v3/examples/notifications/build/windows/nsis/wails_tools.nsh b/v3/examples/notifications/build/windows/nsis/wails_tools.nsh new file mode 100644 index 000000000..c47c784a4 --- /dev/null +++ b/v3/examples/notifications/build/windows/nsis/wails_tools.nsh @@ -0,0 +1,212 @@ +# DO NOT EDIT - Generated automatically by `wails build` + +!include "x64.nsh" +!include "WinVer.nsh" +!include "FileFunc.nsh" + +!ifndef INFO_PROJECTNAME + !define INFO_PROJECTNAME "notifications" +!endif +!ifndef INFO_COMPANYNAME + !define INFO_COMPANYNAME "My Company" +!endif +!ifndef INFO_PRODUCTNAME + !define INFO_PRODUCTNAME "My Product" +!endif +!ifndef INFO_PRODUCTVERSION + !define INFO_PRODUCTVERSION "0.1.0" +!endif +!ifndef INFO_COPYRIGHT + !define INFO_COPYRIGHT "© now, My Company" +!endif +!ifndef PRODUCT_EXECUTABLE + !define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe" +!endif +!ifndef UNINST_KEY_NAME + !define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}" +!endif +!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}" + +!ifndef REQUEST_EXECUTION_LEVEL + !define REQUEST_EXECUTION_LEVEL "admin" +!endif + +RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}" + +!ifdef ARG_WAILS_AMD64_BINARY + !define SUPPORTS_AMD64 +!endif + +!ifdef ARG_WAILS_ARM64_BINARY + !define SUPPORTS_ARM64 +!endif + +!ifdef SUPPORTS_AMD64 + !ifdef SUPPORTS_ARM64 + !define ARCH "amd64_arm64" + !else + !define ARCH "amd64" + !endif +!else + !ifdef SUPPORTS_ARM64 + !define ARCH "arm64" + !else + !error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY" + !endif +!endif + +!macro wails.checkArchitecture + !ifndef WAILS_WIN10_REQUIRED + !define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later." + !endif + + !ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED + !define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}" + !endif + + ${If} ${AtLeastWin10} + !ifdef SUPPORTS_AMD64 + ${if} ${IsNativeAMD64} + Goto ok + ${EndIf} + !endif + + !ifdef SUPPORTS_ARM64 + ${if} ${IsNativeARM64} + Goto ok + ${EndIf} + !endif + + IfSilent silentArch notSilentArch + silentArch: + SetErrorLevel 65 + Abort + notSilentArch: + MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}" + Quit + ${else} + IfSilent silentWin notSilentWin + silentWin: + SetErrorLevel 64 + Abort + notSilentWin: + MessageBox MB_OK "${WAILS_WIN10_REQUIRED}" + Quit + ${EndIf} + + ok: +!macroend + +!macro wails.files + !ifdef SUPPORTS_AMD64 + ${if} ${IsNativeAMD64} + File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}" + ${EndIf} + !endif + + !ifdef SUPPORTS_ARM64 + ${if} ${IsNativeARM64} + File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}" + ${EndIf} + !endif +!macroend + +!macro wails.writeUninstaller + WriteUninstaller "$INSTDIR\uninstall.exe" + + SetRegView 64 + WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}" + WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\"" + WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S" + + ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2 + IntFmt $0 "0x%08X" $0 + WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0" +!macroend + +!macro wails.deleteUninstaller + Delete "$INSTDIR\uninstall.exe" + + SetRegView 64 + DeleteRegKey HKLM "${UNINST_KEY}" +!macroend + +!macro wails.setShellContext + ${If} ${REQUEST_EXECUTION_LEVEL} == "admin" + SetShellVarContext all + ${else} + SetShellVarContext current + ${EndIf} +!macroend + +# Install webview2 by launching the bootstrapper +# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment +!macro wails.webview2runtime + !ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT + !define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime" + !endif + + SetRegView 64 + # If the admin key exists and is not empty then webview2 is already installed + ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" + ${If} $0 != "" + Goto ok + ${EndIf} + + ${If} ${REQUEST_EXECUTION_LEVEL} == "user" + # If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed + ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" + ${If} $0 != "" + Goto ok + ${EndIf} + ${EndIf} + + SetDetailsPrint both + DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}" + SetDetailsPrint listonly + + InitPluginsDir + CreateDirectory "$pluginsdir\webview2bootstrapper" + SetOutPath "$pluginsdir\webview2bootstrapper" + File "MicrosoftEdgeWebview2Setup.exe" + ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install' + + SetDetailsPrint both + ok: +!macroend + +# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b +!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND + ; Backup the previously associated file class + ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" "" + WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0" + + WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}" + + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}` + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}` + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open" + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}` + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}` +!macroend + +!macro APP_UNASSOCIATE EXT FILECLASS + ; Backup the previously associated file class + ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup` + WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0" + + DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}` +!macroend + +!macro wails.associateFiles + ; Create file associations + +!macroend + +!macro wails.unassociateFiles + ; Delete app associations + +!macroend \ No newline at end of file diff --git a/v3/examples/notifications/build/windows/wails.exe.manifest b/v3/examples/notifications/build/windows/wails.exe.manifest new file mode 100644 index 000000000..0299e62ca --- /dev/null +++ b/v3/examples/notifications/build/windows/wails.exe.manifest @@ -0,0 +1,15 @@ + + + + + + + + + + + true/pm + permonitorv2,permonitor + + + \ No newline at end of file diff --git a/v3/examples/notifications/frontend/Inter Font License.txt b/v3/examples/notifications/frontend/Inter Font License.txt new file mode 100644 index 000000000..b525cbf3a --- /dev/null +++ b/v3/examples/notifications/frontend/Inter Font License.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/v3/examples/notifications/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/index.ts b/v3/examples/notifications/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/index.ts new file mode 100644 index 000000000..bbdce6579 --- /dev/null +++ b/v3/examples/notifications/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/index.ts @@ -0,0 +1,13 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +import * as Service from "./service.js"; +export { + Service +}; + +export { + NotificationAction, + NotificationCategory, + NotificationOptions +} from "./models.js"; diff --git a/v3/examples/notifications/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/models.ts b/v3/examples/notifications/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/models.ts new file mode 100644 index 000000000..d7f48edfe --- /dev/null +++ b/v3/examples/notifications/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/models.ts @@ -0,0 +1,107 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import { Create as $Create } from "@wailsio/runtime"; + +/** + * NotificationAction represents an action button for a notification. + */ +export class NotificationAction { + "id"?: string; + "title"?: string; + + /** + * (macOS-specific) + */ + "destructive"?: boolean; + + /** Creates a new NotificationAction instance. */ + constructor($$source: Partial = {}) { + + Object.assign(this, $$source); + } + + /** + * Creates a new NotificationAction instance from a string or object. + */ + static createFrom($$source: any = {}): NotificationAction { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new NotificationAction($$parsedSource as Partial); + } +} + +/** + * NotificationCategory groups actions for notifications. + */ +export class NotificationCategory { + "id"?: string; + "actions"?: NotificationAction[]; + "hasReplyField"?: boolean; + "replyPlaceholder"?: string; + "replyButtonTitle"?: string; + + /** Creates a new NotificationCategory instance. */ + constructor($$source: Partial = {}) { + + Object.assign(this, $$source); + } + + /** + * Creates a new NotificationCategory instance from a string or object. + */ + static createFrom($$source: any = {}): NotificationCategory { + const $$createField1_0 = $$createType1; + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("actions" in $$parsedSource) { + $$parsedSource["actions"] = $$createField1_0($$parsedSource["actions"]); + } + return new NotificationCategory($$parsedSource as Partial); + } +} + +/** + * NotificationOptions contains configuration for a notification + */ +export class NotificationOptions { + "id": string; + "title": string; + + /** + * (macOS and Linux only) + */ + "subtitle"?: string; + "body"?: string; + "categoryId"?: string; + "data"?: { [_: string]: any }; + + /** Creates a new NotificationOptions instance. */ + constructor($$source: Partial = {}) { + if (!("id" in $$source)) { + this["id"] = ""; + } + if (!("title" in $$source)) { + this["title"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new NotificationOptions instance from a string or object. + */ + static createFrom($$source: any = {}): NotificationOptions { + const $$createField5_0 = $$createType2; + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("data" in $$parsedSource) { + $$parsedSource["data"] = $$createField5_0($$parsedSource["data"]); + } + return new NotificationOptions($$parsedSource as Partial); + } +} + +// Private type creation functions +const $$createType0 = NotificationAction.createFrom; +const $$createType1 = $Create.Array($$createType0); +const $$createType2 = $Create.Map($Create.Any, $Create.Any); diff --git a/v3/examples/notifications/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/service.ts b/v3/examples/notifications/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/service.ts new file mode 100644 index 000000000..28f1cb3b2 --- /dev/null +++ b/v3/examples/notifications/frontend/bindings/github.com/wailsapp/wails/v3/pkg/services/notifications/service.ts @@ -0,0 +1,62 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +/** + * Service represents the notifications service + * @module + */ + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as $models from "./models.js"; + +export function CheckNotificationAuthorization(): $CancellablePromise { + return $Call.ByID(2789931702); +} + +export function RegisterNotificationCategory(category: $models.NotificationCategory): $CancellablePromise { + return $Call.ByID(2679064664, category); +} + +export function RemoveAllDeliveredNotifications(): $CancellablePromise { + return $Call.ByID(384520397); +} + +export function RemoveAllPendingNotifications(): $CancellablePromise { + return $Call.ByID(1423986276); +} + +export function RemoveDeliveredNotification(identifier: string): $CancellablePromise { + return $Call.ByID(149440045, identifier); +} + +export function RemoveNotification(identifier: string): $CancellablePromise { + return $Call.ByID(3702062929, identifier); +} + +export function RemoveNotificationCategory(categoryID: string): $CancellablePromise { + return $Call.ByID(229511469, categoryID); +} + +export function RemovePendingNotification(identifier: string): $CancellablePromise { + return $Call.ByID(3872412470, identifier); +} + +/** + * Public methods that delegate to the implementation. + */ +export function RequestNotificationAuthorization(): $CancellablePromise { + return $Call.ByID(729898933); +} + +export function SendNotification(options: $models.NotificationOptions): $CancellablePromise { + return $Call.ByID(2246903123, options); +} + +export function SendNotificationWithActions(options: $models.NotificationOptions): $CancellablePromise { + return $Call.ByID(1615199806, options); +} diff --git a/v3/examples/notifications/frontend/index.html b/v3/examples/notifications/frontend/index.html new file mode 100644 index 000000000..ae067c8a2 --- /dev/null +++ b/v3/examples/notifications/frontend/index.html @@ -0,0 +1,30 @@ + + + + + + + + Wails App + + +
+ +

Wails + Typescript + Desktop Notifications

+

Send notifications 👇

+
+ + +
+ +
+ + + diff --git a/v3/examples/notifications/frontend/package-lock.json b/v3/examples/notifications/frontend/package-lock.json new file mode 100644 index 000000000..cdd50c49d --- /dev/null +++ b/v3/examples/notifications/frontend/package-lock.json @@ -0,0 +1,935 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "devDependencies": { + "@wailsio/runtime": "latest", + "typescript": "^4.9.3", + "vite": "^5.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.37.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.37.0.tgz", + "integrity": "sha512-l7StVw6WAa8l3vA1ov80jyetOAEo1FtHvZDbzXDO/02Sq/QVvqlHkYoFwDJPIMj0GKiistsBudfx5tGFnwYWDQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.37.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.37.0.tgz", + "integrity": "sha512-6U3SlVyMxezt8Y+/iEBcbp945uZjJwjZimu76xoG7tO1av9VO691z8PkhzQ85ith2I8R2RddEPeSfcbyPfD4hA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.37.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.37.0.tgz", + "integrity": "sha512-+iTQ5YHuGmPt10NTzEyMPbayiNTcOZDWsbxZYR1ZnmLnZxG17ivrPSWFO9j6GalY0+gV3Jtwrrs12DBscxnlYA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.37.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.37.0.tgz", + "integrity": "sha512-m8W2UbxLDcmRKVjgl5J/k4B8d7qX2EcJve3Sut7YGrQoPtCIQGPH5AMzuFvYRWZi0FVS0zEY4c8uttPfX6bwYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.37.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.37.0.tgz", + "integrity": "sha512-FOMXGmH15OmtQWEt174v9P1JqqhlgYge/bUjIbiVD1nI1NeJ30HYT9SJlZMqdo1uQFyt9cz748F1BHghWaDnVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.37.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.37.0.tgz", + "integrity": "sha512-SZMxNttjPKvV14Hjck5t70xS3l63sbVwl98g3FlVVx2YIDmfUIy29jQrsw06ewEYQ8lQSuY9mpAPlmgRD2iSsA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.37.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.37.0.tgz", + "integrity": "sha512-hhAALKJPidCwZcj+g+iN+38SIOkhK2a9bqtJR+EtyxrKKSt1ynCBeqrQy31z0oWU6thRZzdx53hVgEbRkuI19w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.37.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.37.0.tgz", + "integrity": "sha512-jUb/kmn/Gd8epbHKEqkRAxq5c2EwRt0DqhSGWjPFxLeFvldFdHQs/n8lQ9x85oAeVb6bHcS8irhTJX2FCOd8Ag==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.37.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.37.0.tgz", + "integrity": "sha512-oNrJxcQT9IcbcmKlkF+Yz2tmOxZgG9D9GRq+1OE6XCQwCVwxixYAa38Z8qqPzQvzt1FCfmrHX03E0pWoXm1DqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.37.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.37.0.tgz", + "integrity": "sha512-pfxLBMls+28Ey2enpX3JvjEjaJMBX5XlPCZNGxj4kdJyHduPBXtxYeb8alo0a7bqOoWZW2uKynhHxF/MWoHaGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.37.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.37.0.tgz", + "integrity": "sha512-yCE0NnutTC/7IGUq/PUHmoeZbIwq3KRh02e9SfFh7Vmc1Z7atuJRYWhRME5fKgT8aS20mwi1RyChA23qSyRGpA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.37.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.37.0.tgz", + "integrity": "sha512-NxcICptHk06E2Lh3a4Pu+2PEdZ6ahNHuK7o6Np9zcWkrBMuv21j10SQDJW3C9Yf/A/P7cutWoC/DptNLVsZ0VQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.37.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.37.0.tgz", + "integrity": "sha512-PpWwHMPCVpFZLTfLq7EWJWvrmEuLdGn1GMYcm5MV7PaRgwCEYJAwiN94uBuZev0/J/hFIIJCsYw4nLmXA9J7Pw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.37.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.37.0.tgz", + "integrity": "sha512-DTNwl6a3CfhGTAOYZ4KtYbdS8b+275LSLqJVJIrPa5/JuIufWWZ/QFvkxp52gpmguN95eujrM68ZG+zVxa8zHA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.37.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.37.0.tgz", + "integrity": "sha512-hZDDU5fgWvDdHFuExN1gBOhCuzo/8TMpidfOR+1cPZJflcEzXdCy1LjnklQdW8/Et9sryOPJAKAQRw8Jq7Tg+A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.37.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.37.0.tgz", + "integrity": "sha512-pKivGpgJM5g8dwj0ywBwe/HeVAUSuVVJhUTa/URXjxvoyTT/AxsLTAbkHkDHG7qQxLoW2s3apEIl26uUe08LVQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.37.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.37.0.tgz", + "integrity": "sha512-E2lPrLKE8sQbY/2bEkVTGDEk4/49UYRVWgj90MY8yPjpnGBQ+Xi1Qnr7b7UIWw1NOggdFQFOLZ8+5CzCiz143w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.37.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.37.0.tgz", + "integrity": "sha512-Jm7biMazjNzTU4PrQtr7VS8ibeys9Pn29/1bm4ph7CP2kf21950LgN+BaE2mJ1QujnvOc6p54eWWiVvn05SOBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.37.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.37.0.tgz", + "integrity": "sha512-e3/1SFm1OjefWICB2Ucstg2dxYDkDTZGDYgwufcbsxTHyqQps1UQf33dFEChBNmeSsTOyrjw2JJq0zbG5GF6RA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.37.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.37.0.tgz", + "integrity": "sha512-LWbXUBwn/bcLx2sSsqy7pK5o+Nr+VCoRoAohfJ5C/aBio9nfJmGQqHAhU6pwxV/RmyTk5AqdySma7uwWGlmeuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@wailsio/runtime": { + "version": "3.0.0-alpha.66", + "resolved": "https://registry.npmjs.org/@wailsio/runtime/-/runtime-3.0.0-alpha.66.tgz", + "integrity": "sha512-ENLu8rn1griL1gFHJqkq1i+BVxrrA0JPJHYneUJYuf/s54kjuQViW0RKDEe/WTDo56ABpfykrd/T8OYpPUyXUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.37.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.37.0.tgz", + "integrity": "sha512-iAtQy/L4QFU+rTJ1YUjXqJOJzuwEghqWzCEYD2FEghT7Gsy1VdABntrO4CLopA5IkflTyqNiLNwPcOJ3S7UKLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.6" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.37.0", + "@rollup/rollup-android-arm64": "4.37.0", + "@rollup/rollup-darwin-arm64": "4.37.0", + "@rollup/rollup-darwin-x64": "4.37.0", + "@rollup/rollup-freebsd-arm64": "4.37.0", + "@rollup/rollup-freebsd-x64": "4.37.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.37.0", + "@rollup/rollup-linux-arm-musleabihf": "4.37.0", + "@rollup/rollup-linux-arm64-gnu": "4.37.0", + "@rollup/rollup-linux-arm64-musl": "4.37.0", + "@rollup/rollup-linux-loongarch64-gnu": "4.37.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.37.0", + "@rollup/rollup-linux-riscv64-gnu": "4.37.0", + "@rollup/rollup-linux-riscv64-musl": "4.37.0", + "@rollup/rollup-linux-s390x-gnu": "4.37.0", + "@rollup/rollup-linux-x64-gnu": "4.37.0", + "@rollup/rollup-linux-x64-musl": "4.37.0", + "@rollup/rollup-win32-arm64-msvc": "4.37.0", + "@rollup/rollup-win32-ia32-msvc": "4.37.0", + "@rollup/rollup-win32-x64-msvc": "4.37.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/vite": { + "version": "5.4.15", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.15.tgz", + "integrity": "sha512-6ANcZRivqL/4WtwPGTKNaosuNJr5tWiftOC7liM7G9+rMb8+oeJeyzymDu4rTN93seySBmbjSfsS3Vzr19KNtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + } + } +} diff --git a/v3/examples/notifications/frontend/package.json b/v3/examples/notifications/frontend/package.json new file mode 100644 index 000000000..4d675f189 --- /dev/null +++ b/v3/examples/notifications/frontend/package.json @@ -0,0 +1,17 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build:dev": "tsc && vite build --minify false --mode development", + "build": "tsc && vite build --mode production", + "preview": "vite preview" + }, + "devDependencies": { + "typescript": "^4.9.3", + "vite": "^5.0.0", + "@wailsio/runtime": "latest" + } +} \ No newline at end of file diff --git a/v3/examples/notifications/frontend/public/Inter-Medium.ttf b/v3/examples/notifications/frontend/public/Inter-Medium.ttf new file mode 100644 index 000000000..a01f3777a Binary files /dev/null and b/v3/examples/notifications/frontend/public/Inter-Medium.ttf differ diff --git a/v3/examples/notifications/frontend/public/style.css b/v3/examples/notifications/frontend/public/style.css new file mode 100644 index 000000000..074717bca --- /dev/null +++ b/v3/examples/notifications/frontend/public/style.css @@ -0,0 +1,131 @@ +:root { + font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", + "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", + sans-serif; + font-size: 16px; + line-height: 24px; + font-weight: 400; + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: rgba(27, 38, 54, 1); + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-text-size-adjust: 100%; +} + +* { + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; +} + +@font-face { + font-family: "Inter"; + font-style: normal; + font-weight: 400; + src: local(""), + url("./Inter-Medium.ttf") format("truetype"); +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} + +a:hover { + color: #535bf2; +} + +.controls { + display: flex; + gap: 1em; +} + +button { + height: 30px; + line-height: 30px; + border-radius: 3px; + border: none; + margin: 0 0 0 20px; + padding: 0 8px; + cursor: pointer; +} + +body { + margin: 0; + display: flex; + place-items: center; + place-content: center; + min-width: 320px; + min-height: 100vh; +} + +.container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3em; +} + +h1, h3 { + line-height: 1.1; + text-align: center; +} + +#app { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; +} + +.logo:hover { + filter: drop-shadow(0 0 2em #e80000aa); +} + +.logo.vanilla:hover { + filter: drop-shadow(0 0 2em #f7df1eaa); +} + +.footer { + margin-top: 1rem; + align-content: center; + text-align: center; +} + +.footer table { + font-size: 12px; + border-collapse: collapse; + margin: 0 auto; +} + +.footer table, th, td { + border: 1px solid #ddd; + padding: 0.5em; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + + a:hover { + color: #747bff; + } + + button { + background-color: #f9f9f9; + } +} \ No newline at end of file diff --git a/v3/examples/notifications/frontend/public/typescript.svg b/v3/examples/notifications/frontend/public/typescript.svg new file mode 100644 index 000000000..d91c910cc --- /dev/null +++ b/v3/examples/notifications/frontend/public/typescript.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/v3/examples/notifications/frontend/public/wails.png b/v3/examples/notifications/frontend/public/wails.png new file mode 100644 index 000000000..8bdf42483 Binary files /dev/null and b/v3/examples/notifications/frontend/public/wails.png differ diff --git a/v3/examples/notifications/frontend/src/main.ts b/v3/examples/notifications/frontend/src/main.ts new file mode 100644 index 000000000..437fb8c94 --- /dev/null +++ b/v3/examples/notifications/frontend/src/main.ts @@ -0,0 +1,95 @@ +import { Events } from "@wailsio/runtime"; +import * as Notifications from "../bindings/github.com/wailsapp/wails/v3/pkg/services/notifications"; + +document.querySelector("#basic")?.addEventListener("click", async () => { + try { + const authorized = await Notifications.Service.CheckNotificationAuthorization(); + if (authorized) { + await Notifications.Service.SendNotification({ + id: crypto.randomUUID(), + title: "Notification Title", + subtitle: "Subtitle on macOS and Linux", + body: "Body text of notification.", + data: { + "user-id": "user-123", + "message-id": "msg-123", + "timestamp": Date.now(), + }, + }); + } else { + console.warn("Notifications are not authorized.\n You can attempt to request again or let the user know in the UI.\n"); + } + } catch (error) { + console.error(error); + } +}); +document.querySelector("#complex")?.addEventListener("click", async () => { + try { + const authorized = await Notifications.Service.CheckNotificationAuthorization(); + if (authorized) { + const CategoryID = "frontend-notification-id"; + + await Notifications.Service.RegisterNotificationCategory({ + id: CategoryID, + actions: [ + { id: "VIEW", title: "View" }, + { id: "MARK_READ", title: "Mark as read" }, + { id: "DELETE", title: "Delete", destructive: true }, + ], + hasReplyField: true, + replyPlaceholder: "Message...", + replyButtonTitle: "Reply", + }); + + await Notifications.Service.SendNotificationWithActions({ + id: crypto.randomUUID(), + title: "Notification Title", + subtitle: "Subtitle on macOS and Linux", + body: "Body text of notification.", + categoryId: CategoryID, + data: { + "user-id": "user-123", + "message-id": "msg-123", + "timestamp": Date.now(), + }, + }); + } else { + console.warn("Notifications are not authorized.\n You can attempt to request again or let the user know in the UI.\n"); + } + } catch (error) { + console.error(error); + } +}); + +const unlisten = Events.On("notification:action", (response) => { + console.info(`Recieved a ${response.name} event`); + const { userInfo, ...base } = response.data[0]; + console.info("Notification Response:"); + console.table(base); + console.info("Notification Response Metadata:"); + console.table(userInfo); + const table = ` +
Notification Response
+ + + ${Object.keys(base).map(key => ``).join("")} + + + ${Object.values(base).map(value => ``).join("")} + +
${key}
${value}
+
Notification Metadata
+ + + ${Object.keys(userInfo).map(key => ``).join("")} + + + ${Object.values(userInfo).map(value => ``).join("")} + +
${key}
${value}
+ `; + const footer = document.querySelector("#response"); + if (footer) footer.innerHTML = table; +}); + +window.onbeforeunload = () => unlisten(); \ No newline at end of file diff --git a/v3/examples/notifications/frontend/src/vite-env.d.ts b/v3/examples/notifications/frontend/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/v3/examples/notifications/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/v3/examples/notifications/frontend/tsconfig.json b/v3/examples/notifications/frontend/tsconfig.json new file mode 100644 index 000000000..c267ecf24 --- /dev/null +++ b/v3/examples/notifications/frontend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ESNext", "DOM"], + "moduleResolution": "Node", + "strict": true, + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "noEmit": true, + "noUnusedLocals": true, + "noUnusedParameters": false, + "noImplicitAny": false, + "noImplicitReturns": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/v3/examples/notifications/go.mod b/v3/examples/notifications/go.mod new file mode 100644 index 000000000..39537e938 --- /dev/null +++ b/v3/examples/notifications/go.mod @@ -0,0 +1,53 @@ +module notifications + +go 1.24.0 + +toolchain go1.24.1 + +require github.com/wailsapp/wails/v3 v3.0.0-dev + +require ( + dario.cat/mergo v1.0.1 // indirect + git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.1.6 // indirect + github.com/adrg/xdg v0.5.3 // indirect + github.com/bep/debounce v1.2.1 // indirect + github.com/cloudflare/circl v1.6.0 // indirect + github.com/cyphar/filepath-securejoin v0.4.1 // indirect + github.com/ebitengine/purego v0.8.2 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.6.2 // indirect + github.com/go-git/go-git/v5 v5.13.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/leaanthony/go-ansi-parser v1.6.1 // indirect + github.com/leaanthony/u v1.1.1 // indirect + github.com/lmittmann/tint v1.0.7 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/pjbgf/sha1cd v0.3.2 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/samber/lo v1.49.1 // indirect + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/skeema/knownhosts v1.3.1 // indirect + github.com/wailsapp/go-webview2 v1.0.21 // indirect + github.com/wailsapp/mimetype v1.4.1 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + golang.org/x/crypto v0.36.0 // indirect + golang.org/x/net v0.37.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect +) + +replace github.com/wailsapp/wails/v3 => ../wails/v3 diff --git a/v3/examples/notifications/main.go b/v3/examples/notifications/main.go new file mode 100644 index 000000000..264e7273e --- /dev/null +++ b/v3/examples/notifications/main.go @@ -0,0 +1,154 @@ +package main + +import ( + "embed" + _ "embed" + "fmt" + "log" + "time" + + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/events" + "github.com/wailsapp/wails/v3/pkg/services/notifications" +) + +// Wails uses Go's `embed` package to embed the frontend files into the binary. +// Any files in the frontend/dist folder will be embedded into the binary and +// made available to the frontend. +// See https://pkg.go.dev/embed for more information. + +//go:embed all:frontend/dist +var assets embed.FS + +// main function serves as the application's entry point. It initializes the application, creates a window, +// and starts a goroutine that emits a time-based event every second. It subsequently runs the application and +// logs any error that might occur. +func main() { + // Create a new Notification Service + ns := notifications.New() + + // Create a new Wails application by providing the necessary options. + // Variables 'Name' and 'Description' are for application metadata. + // 'Assets' configures the asset server with the 'FS' variable pointing to the frontend files. + // 'Bind' is a list of Go struct instances. The frontend has access to the methods of these instances. + // 'Mac' options tailor the application when running an macOS. + app := application.New(application.Options{ + Name: "Notifications Demo", + Description: "A demo of using desktop notifications with Wails", + Services: []application.Service{ + application.NewService(ns), + }, + Assets: application.AssetOptions{ + Handler: application.AssetFileServerFS(assets), + }, + Mac: application.MacOptions{ + ApplicationShouldTerminateAfterLastWindowClosed: true, + }, + }) + + // Create a new window with the necessary options. + // 'Title' is the title of the window. + // 'Mac' options tailor the window when running on macOS. + // 'BackgroundColour' is the background colour of the window. + // 'URL' is the URL that will be loaded into the webview. + app.NewWebviewWindowWithOptions(application.WebviewWindowOptions{ + Title: "Window 1", + Mac: application.MacWindow{ + InvisibleTitleBarHeight: 50, + Backdrop: application.MacBackdropTranslucent, + TitleBar: application.MacTitleBarHiddenInset, + }, + BackgroundColour: application.NewRGB(27, 38, 54), + URL: "/", + }) + + app.OnApplicationEvent(events.Common.ApplicationStarted, func(event *application.ApplicationEvent) { + // Create a goroutine that spawns desktop notifications from Go + go func() { + var authorized bool + var err error + authorized, err = ns.CheckNotificationAuthorization() + if err != nil { + println(fmt.Errorf("checking app notification authorization failed: %s", err)) + } + + if !authorized { + authorized, err = ns.RequestNotificationAuthorization() + if err != nil { + println(fmt.Errorf("requesting app notification authorization failed: %s", err)) + } + } + + if authorized { + ns.OnNotificationResponse(func(result notifications.NotificationResult) { + if result.Error != nil { + println(fmt.Errorf("parsing notification result failed: %s", result.Error)) + } else { + fmt.Printf("Response: %+v\n", result.Response) + println("Sending response to frontend...") + app.EmitEvent("notification:action", result.Response) + } + }) + + err = ns.SendNotification(notifications.NotificationOptions{ + ID: "uuid-basic-1", + Title: "Notification Title", + Subtitle: "Subtitle on macOS and Linux", + Body: "Body text of notification.", + Data: map[string]interface{}{ + "user-id": "user-123", + "message-id": "msg-123", + "timestamp": time.Now().Unix(), + }, + }) + if err != nil { + println(fmt.Errorf("sending basic notification failed: %s", err)) + } + + // Delay before sending next notification + time.Sleep(time.Second * 2) + + const CategoryID = "backend-notification-id" + + err = ns.RegisterNotificationCategory(notifications.NotificationCategory{ + ID: CategoryID, + Actions: []notifications.NotificationAction{ + {ID: "VIEW", Title: "View"}, + {ID: "MARK_READ", Title: "Mark as read"}, + {ID: "DELETE", Title: "Delete", Destructive: true}, + }, + HasReplyField: true, + ReplyPlaceholder: "Message...", + ReplyButtonTitle: "Reply", + }) + if err != nil { + println(fmt.Errorf("creating notification category failed: %s", err)) + } + + err = ns.SendNotificationWithActions(notifications.NotificationOptions{ + ID: "uuid-with-actions-1", + Title: "Actions Notification Title", + Subtitle: "Subtitle on macOS and Linux", + Body: "Body text of notification with actions.", + CategoryID: CategoryID, + Data: map[string]interface{}{ + "user-id": "user-123", + "message-id": "msg-123", + "timestamp": time.Now().Unix(), + }, + }) + if err != nil { + println(fmt.Errorf("sending notification with actions failed: %s", err)) + } + } + }() + }) + + // Run the application. This blocks until the application has been exited. + err := app.Run() + + // If an error occurred while running the application, log it and exit. + if err != nil { + log.Fatal(err) + } +} diff --git a/v3/go.mod b/v3/go.mod index 6f80abb19..947f0495f 100644 --- a/v3/go.mod +++ b/v3/go.mod @@ -3,6 +3,7 @@ module github.com/wailsapp/wails/v3 go 1.24.0 require ( + git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 github.com/Masterminds/semver v1.5.0 github.com/adrg/xdg v0.5.3 github.com/atterpac/refresh v0.8.6 diff --git a/v3/go.sum b/v3/go.sum index 07fc3d87a..2c07fe6c8 100644 --- a/v3/go.sum +++ b/v3/go.sum @@ -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.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1/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/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w= github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0= github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= diff --git a/v3/pkg/application/application.go b/v3/pkg/application/application.go index 21c0792cc..b3a8f6f34 100644 --- a/v3/pkg/application/application.go +++ b/v3/pkg/application/application.go @@ -345,6 +345,10 @@ type App struct { singleInstanceManager *singleInstanceManager } +func (a *App) Config() Options { + return a.options +} + func (a *App) handleWarning(msg string) { if a.options.WarningHandler != nil { a.options.WarningHandler(msg) diff --git a/v3/pkg/services/notifications/notifications.go b/v3/pkg/services/notifications/notifications.go new file mode 100644 index 000000000..1a33d6d56 --- /dev/null +++ b/v3/pkg/services/notifications/notifications.go @@ -0,0 +1,216 @@ +// Package notifications provides cross-platform notification capabilities for desktop applications. +// It supports macOS, Windows, and Linux with a consistent API while handling platform-specific +// differences internally. Key features include: +// - Basic notifications with title, subtitle, and body +// - Interactive notifications with buttons and actions +// - Notification categories for reusing configurations +// - User feedback handling with a unified callback system +// +// Platform-specific notes: +// - macOS: Requires a properly bundled and signed application +// - Windows: Uses Windows Toast notifications +// - Linux: Uses D-Bus and does not support text inputs +package notifications + +import ( + "context" + "fmt" + "sync" + + "github.com/wailsapp/wails/v3/pkg/application" +) + +type platformNotifier interface { + // Lifecycle methods + Startup(ctx context.Context, options application.ServiceOptions) error + Shutdown() error + + // Core notification methods + RequestNotificationAuthorization() (bool, error) + CheckNotificationAuthorization() (bool, error) + SendNotification(options NotificationOptions) error + SendNotificationWithActions(options NotificationOptions) error + + // Category management + RegisterNotificationCategory(category NotificationCategory) error + RemoveNotificationCategory(categoryID string) error + + // Notification management + RemoveAllPendingNotifications() error + RemovePendingNotification(identifier string) error + RemoveAllDeliveredNotifications() error + RemoveDeliveredNotification(identifier string) error + RemoveNotification(identifier string) error +} + +// Service represents the notifications service +type Service struct { + impl platformNotifier + + // notificationResponseCallback is called when a notification result is received. + // Only one callback can be assigned at a time. + notificationResultCallback func(result NotificationResult) + + callbackLock sync.RWMutex +} + +var ( + notificationServiceOnce sync.Once + NotificationService *Service + notificationServiceLock sync.RWMutex +) + +// 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"` +} + +// 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"` +} + +const DefaultActionIdentifier = "DEFAULT_ACTION" + +// 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:"categoryIdentifier,omitempty"` + 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 +} + +// ServiceName returns the name of the service. +func (ns *Service) ServiceName() string { + return "github.com/wailsapp/wails/v3/services/notifications" +} + +// OnNotificationResponse registers a callback function that will be called when +// a notification response is received from the user. +// +//wails:ignore +func (ns *Service) OnNotificationResponse(callback func(result NotificationResult)) { + ns.callbackLock.Lock() + defer ns.callbackLock.Unlock() + + ns.notificationResultCallback = callback +} + +// handleNotificationResponse is an internal method to handle notification responses +// and invoke the registered callback if one exists. +func (ns *Service) handleNotificationResult(result NotificationResult) { + ns.callbackLock.RLock() + callback := ns.notificationResultCallback + ns.callbackLock.RUnlock() + + if callback != nil { + callback(result) + } +} + +// ServiceStartup is called when the service is loaded. +func (ns *Service) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { + return ns.impl.Startup(ctx, options) +} + +// ServiceShutdown is called when the service is unloaded. +func (ns *Service) ServiceShutdown() error { + return ns.impl.Shutdown() +} + +// Public methods that delegate to the implementation. +func (ns *Service) RequestNotificationAuthorization() (bool, error) { + return ns.impl.RequestNotificationAuthorization() +} + +func (ns *Service) CheckNotificationAuthorization() (bool, error) { + return ns.impl.CheckNotificationAuthorization() +} + +func (ns *Service) SendNotification(options NotificationOptions) error { + if err := validateNotificationOptions(options); err != nil { + return err + } + return ns.impl.SendNotification(options) +} + +func (ns *Service) SendNotificationWithActions(options NotificationOptions) error { + if err := validateNotificationOptions(options); err != nil { + return err + } + return ns.impl.SendNotificationWithActions(options) +} + +func (ns *Service) RegisterNotificationCategory(category NotificationCategory) error { + return ns.impl.RegisterNotificationCategory(category) +} + +func (ns *Service) RemoveNotificationCategory(categoryID string) error { + return ns.impl.RemoveNotificationCategory(categoryID) +} + +func (ns *Service) RemoveAllPendingNotifications() error { + return ns.impl.RemoveAllPendingNotifications() +} + +func (ns *Service) RemovePendingNotification(identifier string) error { + return ns.impl.RemovePendingNotification(identifier) +} + +func (ns *Service) RemoveAllDeliveredNotifications() error { + return ns.impl.RemoveAllDeliveredNotifications() +} + +func (ns *Service) RemoveDeliveredNotification(identifier string) error { + return ns.impl.RemoveDeliveredNotification(identifier) +} + +func (ns *Service) RemoveNotification(identifier string) error { + return ns.impl.RemoveNotification(identifier) +} + +func getNotificationService() *Service { + notificationServiceLock.RLock() + defer notificationServiceLock.RUnlock() + return NotificationService +} + +// validateNotificationOptions validates an ID and Title are provided for notifications. +func validateNotificationOptions(options NotificationOptions) error { + if options.ID == "" { + return fmt.Errorf("notification ID cannot be empty") + } + + if options.Title == "" { + return fmt.Errorf("notification title cannot be empty") + } + + return nil +} diff --git a/v3/pkg/services/notifications/notifications_darwin.go b/v3/pkg/services/notifications/notifications_darwin.go new file mode 100644 index 000000000..2c8f33d15 --- /dev/null +++ b/v3/pkg/services/notifications/notifications_darwin.go @@ -0,0 +1,423 @@ +//go:build darwin + +package notifications + +/* +#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 "./notifications_darwin.h" +*/ +import "C" +import ( + "context" + "encoding/json" + "fmt" + "sync" + "time" + "unsafe" + + "github.com/wailsapp/wails/v3/pkg/application" +) + +type darwinNotifier struct { + channels map[int]chan notificationChannel + channelsLock sync.Mutex + nextChannelID int +} + +type notificationChannel struct { + Success bool + Error error +} + +type ChannelHandler interface { + GetChannel(id int) (chan notificationChannel, bool) +} + +const AppleDefaultActionIdentifier = "com.apple.UNNotificationDefaultActionIdentifier" + +// Creates a new Notifications Service. +// Your app must be packaged and signed for this feature to work. +func New() *Service { + notificationServiceOnce.Do(func() { + impl := &darwinNotifier{ + channels: make(map[int]chan notificationChannel), + nextChannelID: 0, + } + + NotificationService = &Service{ + impl: impl, + } + }) + + return NotificationService +} + +func (dn *darwinNotifier) Startup(ctx context.Context, options application.ServiceOptions) error { + if !isNotificationAvailable() { + return fmt.Errorf("notifications are not available on this system") + } + if !checkBundleIdentifier() { + return fmt.Errorf("notifications require a valid bundle identifier") + } + return nil +} + +func (dn *darwinNotifier) Shutdown() error { + return nil +} + +// isNotificationAvailable checks if notifications are available on the system. +func isNotificationAvailable() bool { + return bool(C.isNotificationAvailable()) +} + +func checkBundleIdentifier() bool { + return bool(C.checkBundleIdentifier()) +} + +// RequestNotificationAuthorization requests permission for notifications. +// Default timeout is 3 minutes +func (dn *darwinNotifier) RequestNotificationAuthorization() (bool, error) { + ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second) + defer cancel() + + id, resultCh := dn.registerChannel() + + C.requestNotificationAuthorization(C.int(id)) + + select { + case result := <-resultCh: + return result.Success, result.Error + case <-ctx.Done(): + dn.cleanupChannel(id) + return false, fmt.Errorf("notification authorization timed out after 3 minutes: %w", ctx.Err()) + } +} + +// CheckNotificationAuthorization checks current notification permission status. +func (dn *darwinNotifier) CheckNotificationAuthorization() (bool, error) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + id, resultCh := dn.registerChannel() + + C.checkNotificationAuthorization(C.int(id)) + + select { + case result := <-resultCh: + return result.Success, result.Error + case <-ctx.Done(): + dn.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 (dn *darwinNotifier) SendNotification(options 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 := dn.registerChannel() + C.sendNotification(C.int(id), cIdentifier, cTitle, cSubtitle, cBody, cDataJSON) + + select { + case result := <-resultCh: + if !result.Success { + if result.Error != nil { + return result.Error + } + return fmt.Errorf("sending notification failed") + } + return nil + case <-ctx.Done(): + dn.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 (dn *darwinNotifier) SendNotificationWithActions(options 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 := dn.registerChannel() + C.sendNotificationWithActions(C.int(id), cIdentifier, cTitle, cSubtitle, cBody, cCategoryID, cDataJSON) + + select { + case result := <-resultCh: + if !result.Success { + if result.Error != nil { + return result.Error + } + return fmt.Errorf("sending notification failed") + } + return nil + case <-ctx.Done(): + dn.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 (dn *darwinNotifier) RegisterNotificationCategory(category 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 := dn.registerChannel() + C.registerNotificationCategory(C.int(id), cCategoryID, cActionsJSON, C.bool(category.HasReplyField), + cReplyPlaceholder, cReplyButtonTitle) + + select { + case result := <-resultCh: + if !result.Success { + if result.Error != nil { + return result.Error + } + return fmt.Errorf("category registration failed") + } + return nil + case <-ctx.Done(): + dn.cleanupChannel(id) + return fmt.Errorf("category registration timed out: %w", ctx.Err()) + } +} + +// RemoveNotificationCategory remove a previously registered NotificationCategory. +func (dn *darwinNotifier) 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 := dn.registerChannel() + C.removeNotificationCategory(C.int(id), cCategoryID) + + select { + case result := <-resultCh: + if !result.Success { + if result.Error != nil { + return result.Error + } + return fmt.Errorf("category removal failed") + } + return nil + case <-ctx.Done(): + dn.cleanupChannel(id) + return fmt.Errorf("category removal timed out: %w", ctx.Err()) + } +} + +// RemoveAllPendingNotifications removes all pending notifications. +func (dn *darwinNotifier) RemoveAllPendingNotifications() error { + C.removeAllPendingNotifications() + return nil +} + +// RemovePendingNotification removes a pending notification matching the unique identifier. +func (dn *darwinNotifier) RemovePendingNotification(identifier string) error { + cIdentifier := C.CString(identifier) + defer C.free(unsafe.Pointer(cIdentifier)) + C.removePendingNotification(cIdentifier) + return nil +} + +// RemoveAllDeliveredNotifications removes all delivered notifications. +func (dn *darwinNotifier) RemoveAllDeliveredNotifications() error { + C.removeAllDeliveredNotifications() + return nil +} + +// RemoveDeliveredNotification removes a delivered notification matching the unique identifier. +func (dn *darwinNotifier) RemoveDeliveredNotification(identifier string) error { + cIdentifier := C.CString(identifier) + defer C.free(unsafe.Pointer(cIdentifier)) + C.removeDeliveredNotification(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 (dn *darwinNotifier) RemoveNotification(identifier string) error { + return nil +} + +//export captureResult +func captureResult(channelID C.int, success C.bool, errorMsg *C.char) { + ns := getNotificationService() + if ns == nil { + return + } + + handler, ok := ns.impl.(ChannelHandler) + if !ok { + return + } + + resultCh, exists := handler.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, + } + + close(resultCh) +} + +//export didReceiveNotificationResponse +func didReceiveNotificationResponse(jsonPayload *C.char, err *C.char) { + result := NotificationResult{} + + if err != nil { + errMsg := C.GoString(err) + result.Error = fmt.Errorf("notification response error: %s", errMsg) + if ns := getNotificationService(); ns != nil { + ns.handleNotificationResult(result) + } + return + } + + if jsonPayload == nil { + result.Error = fmt.Errorf("received nil JSON payload in notification response") + if ns := getNotificationService(); ns != nil { + ns.handleNotificationResult(result) + } + return + } + + payload := C.GoString(jsonPayload) + + var response NotificationResponse + if err := json.Unmarshal([]byte(payload), &response); err != nil { + result.Error = fmt.Errorf("failed to unmarshal notification response: %w", err) + if ns := getNotificationService(); ns != nil { + ns.handleNotificationResult(result) + } + return + } + + if response.ActionIdentifier == AppleDefaultActionIdentifier { + response.ActionIdentifier = DefaultActionIdentifier + } + + result.Response = response + if ns := getNotificationService(); ns != nil { + ns.handleNotificationResult(result) + } +} + +// Helper methods + +func (dn *darwinNotifier) registerChannel() (int, chan notificationChannel) { + dn.channelsLock.Lock() + defer dn.channelsLock.Unlock() + + id := dn.nextChannelID + dn.nextChannelID++ + + resultCh := make(chan notificationChannel, 1) + + dn.channels[id] = resultCh + return id, resultCh +} + +func (dn *darwinNotifier) GetChannel(id int) (chan notificationChannel, bool) { + dn.channelsLock.Lock() + defer dn.channelsLock.Unlock() + + ch, exists := dn.channels[id] + if exists { + delete(dn.channels, id) + } + return ch, exists +} + +func (dn *darwinNotifier) cleanupChannel(id int) { + dn.channelsLock.Lock() + defer dn.channelsLock.Unlock() + + if ch, exists := dn.channels[id]; exists { + delete(dn.channels, id) + close(ch) + } +} diff --git a/v3/pkg/services/notifications/notifications_darwin.h b/v3/pkg/services/notifications/notifications_darwin.h new file mode 100644 index 000000000..7cd505240 --- /dev/null +++ b/v3/pkg/services/notifications/notifications_darwin.h @@ -0,0 +1,21 @@ +//go:build darwin + +#ifndef NOTIFICATIONS_DARWIN_H +#define NOTIFICATIONS_DARWIN_H + +#import + +bool checkBundleIdentifier(void); +bool isNotificationAvailable(void); +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 *data_json); +void sendNotificationWithActions(int channelID, const char *identifier, const char *title, const char *subtitle, const char *body, const char *categoryId, const char *actions_json); +void registerNotificationCategory(int channelID, const char *categoryId, const char *actions_json, bool hasReplyField, const char *replyPlaceholder, const char *replyButtonTitle); +void removeNotificationCategory(int channelID, const char *categoryId); +void removeAllPendingNotifications(void); +void removePendingNotification(const char *identifier); +void removeAllDeliveredNotifications(void); +void removeDeliveredNotification(const char *identifier); + +#endif /* NOTIFICATIONS_DARWIN_H */ \ No newline at end of file diff --git a/v3/pkg/services/notifications/notifications_darwin.m b/v3/pkg/services/notifications/notifications_darwin.m new file mode 100644 index 000000000..6c2048b74 --- /dev/null +++ b/v3/pkg/services/notifications/notifications_darwin.m @@ -0,0 +1,377 @@ +#import "notifications_darwin.h" +#include +#import + +#if __MAC_OS_X_VERSION_MAX_ALLOWED >= 110000 +#import +#endif + +bool isNotificationAvailable(void) { + if (@available(macOS 11.0, *)) { + return YES; + } else { + return NO; + } +} + +bool checkBundleIdentifier(void) { + NSBundle *main = [NSBundle mainBundle]; + if (main.bundleIdentifier == nil) { + return NO; + } + return YES; +} + +extern void captureResult(int channelID, bool success, const char* error); +extern void didReceiveNotificationResponse(const char *jsonPayload, const char* error); + +@interface NotificationsDelegate : NSObject +@end + +@implementation NotificationsDelegate + +- (void)userNotificationCenter:(UNUserNotificationCenter *)center + willPresentNotification:(UNNotification *)notification + withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler { + UNNotificationPresentationOptions options = 0; + + 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 { + + 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:@"categoryIdentifier"]; + } + + 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(); +} + +@end + +static NotificationsDelegate *delegateInstance = nil; +static dispatch_once_t onceToken; + +static BOOL ensureDelegateInitialized(void) { + __block BOOL success = YES; + + dispatch_once(&onceToken, ^{ + delegateInstance = [[NotificationsDelegate alloc] init]; + UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter]; + center.delegate = delegateInstance; + }); + + if (!delegateInstance) { + success = NO; + } + + return success; +} + +void requestNotificationAuthorization(int channelID) { + if (!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); + } + }]; +} + +void checkNotificationAuthorization(int channelID) { + if (!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); + }]; +} + +// Helper function to create notification content +UNMutableNotificationContent* createNotificationContent(const char *title, const char *subtitle, + const char *body, const char *data_json, NSError **contentError) { + NSString *nsTitle = [NSString stringWithUTF8String:title]; + NSString *nsSubtitle = subtitle ? [NSString stringWithUTF8String:subtitle] : @""; + NSString *nsBody = [NSString stringWithUTF8String:body]; + + UNMutableNotificationContent *content = [[UNMutableNotificationContent alloc] init]; + content.title = nsTitle; + if (![nsSubtitle isEqualToString:@""]) { + content.subtitle = nsSubtitle; + } + content.body = nsBody; + content.sound = [UNNotificationSound defaultSound]; + + // Parse JSON data if provided + if (data_json) { + NSString *dataJsonStr = [NSString stringWithUTF8String:data_json]; + 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) { + *contentError = error; + } + } + + return content; +} + +void sendNotification(int channelID, const char *identifier, const char *title, const char *subtitle, const char *body, const char *data_json) { + if (!ensureDelegateInitialized()) { + NSString *errorMsg = @"Notification delegate has been lost. Reinitialize the notification service."; + captureResult(channelID, false, [errorMsg UTF8String]); + return; + } + + UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter]; + + NSString *nsIdentifier = [NSString stringWithUTF8String:identifier]; + + NSError *contentError = nil; + UNMutableNotificationContent *content = createNotificationContent(title, subtitle, body, data_json, &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]; + + [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 sendNotificationWithActions(int channelID, const char *identifier, const char *title, const char *subtitle, + const char *body, const char *categoryId, const char *data_json) { + if (!ensureDelegateInitialized()) { + NSString *errorMsg = @"Notification delegate has been lost. Reinitialize the notification service."; + captureResult(channelID, false, [errorMsg UTF8String]); + return; + } + + UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter]; + + NSString *nsIdentifier = [NSString stringWithUTF8String:identifier]; + NSString *nsCategoryId = [NSString stringWithUTF8String:categoryId]; + + NSError *contentError = nil; + UNMutableNotificationContent *content = createNotificationContent(title, subtitle, body, data_json, &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]; + + [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 registerNotificationCategory(int channelID, const char *categoryId, const char *actions_json, bool hasReplyField, + const char *replyPlaceholder, const char *replyButtonTitle) { + if (!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 = actions_json ? [NSString stringWithUTF8String:actions_json] : @"[]"; + + 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 && replyPlaceholder && replyButtonTitle) { + 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 *categories) { + NSMutableSet *updatedCategories = [NSMutableSet setWithSet:categories]; + + // Remove existing category with same ID if it exists + 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) { + NSString *nsCategoryId = [NSString stringWithUTF8String:categoryId]; + UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter]; + + [center getNotificationCategoriesWithCompletionHandler:^(NSSet *categories) { + NSMutableSet *updatedCategories = [NSMutableSet setWithSet:categories]; + + // Find and remove the category with matching identifier + 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(void) { + UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter]; + [center removeAllPendingNotificationRequests]; +} + +void removePendingNotification(const char *identifier) { + NSString *nsIdentifier = [NSString stringWithUTF8String:identifier]; + UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter]; + [center removePendingNotificationRequestsWithIdentifiers:@[nsIdentifier]]; +} + +void removeAllDeliveredNotifications(void) { + UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter]; + [center removeAllDeliveredNotifications]; +} + +void removeDeliveredNotification(const char *identifier) { + NSString *nsIdentifier = [NSString stringWithUTF8String:identifier]; + UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter]; + [center removeDeliveredNotificationsWithIdentifiers:@[nsIdentifier]]; +} \ No newline at end of file diff --git a/v3/pkg/services/notifications/notifications_linux.go b/v3/pkg/services/notifications/notifications_linux.go new file mode 100644 index 000000000..bdc8312ff --- /dev/null +++ b/v3/pkg/services/notifications/notifications_linux.go @@ -0,0 +1,565 @@ +//go:build linux + +package notifications + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + + "github.com/godbus/dbus/v5" + "github.com/wailsapp/wails/v3/pkg/application" +) + +type linuxNotifier struct { + conn *dbus.Conn + categories map[string]NotificationCategory + categoriesLock sync.RWMutex + notifications map[uint32]*notificationData + notificationsLock 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" +) + +// Creates a new Notifications Service. +func New() *Service { + notificationServiceOnce.Do(func() { + impl := &linuxNotifier{ + categories: make(map[string]NotificationCategory), + notifications: make(map[uint32]*notificationData), + } + + NotificationService = &Service{ + impl: impl, + } + }) + + return NotificationService +} + +// Startup is called when the service is loaded. +func (ln *linuxNotifier) Startup(ctx context.Context, options application.ServiceOptions) error { + ln.appName = application.Get().Config().Name + + conn, err := dbus.ConnectSessionBus() + if err != nil { + return fmt.Errorf("failed to connect to session bus: %w", err) + } + ln.conn = conn + + if err := ln.loadCategories(); err != nil { + fmt.Printf("Failed to load notification categories: %v\n", err) + } + + var signalCtx context.Context + signalCtx, ln.cancel = context.WithCancel(context.Background()) + + if err := ln.setupSignalHandling(signalCtx); err != nil { + return fmt.Errorf("failed to set up notification signal handling: %w", err) + } + + return nil +} + +// Shutdown will save categories and close the D-Bus connection when the service unloads. +func (ln *linuxNotifier) Shutdown() error { + if ln.cancel != nil { + ln.cancel() + } + + if err := ln.saveCategories(); err != nil { + fmt.Printf("Failed to save notification categories: %v\n", err) + } + + if ln.conn != nil { + return ln.conn.Close() + } + return nil +} + +// RequestNotificationAuthorization is a Linux stub that always returns true, nil. +// (authorization is macOS-specific) +func (ln *linuxNotifier) RequestNotificationAuthorization() (bool, error) { + return true, nil +} + +// CheckNotificationAuthorization is a Linux stub that always returns true. +// (authorization is macOS-specific) +func (ln *linuxNotifier) CheckNotificationAuthorization() (bool, error) { + return true, nil +} + +// SendNotification sends a basic notification with a unique identifier, title, subtitle, and body. +func (ln *linuxNotifier) SendNotification(options NotificationOptions) error { + 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 := ln.conn.Object(dbusNotificationInterface, dbusNotificationPath) + call := obj.Call( + dbusNotificationInterface+".Notify", + 0, + ln.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, + } + + ln.notificationsLock.Lock() + ln.notifications[dbusID] = notification + ln.notificationsLock.Unlock() + + return nil +} + +// SendNotificationWithActions sends a notification with additional actions. +func (ln *linuxNotifier) SendNotificationWithActions(options NotificationOptions) error { + ln.categoriesLock.RLock() + category, exists := ln.categories[options.CategoryID] + ln.categoriesLock.RUnlock() + + if options.CategoryID == "" || !exists { + // Fall back to basic notification + return ln.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 := ln.conn.Object(dbusNotificationInterface, dbusNotificationPath) + call := obj.Call( + dbusNotificationInterface+".Notify", + 0, + ln.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, + } + + ln.notificationsLock.Lock() + ln.notifications[dbusID] = notification + ln.notificationsLock.Unlock() + + return nil +} + +// RegisterNotificationCategory registers a new NotificationCategory to be used with SendNotificationWithActions. +func (ln *linuxNotifier) RegisterNotificationCategory(category NotificationCategory) error { + ln.categoriesLock.Lock() + ln.categories[category.ID] = category + ln.categoriesLock.Unlock() + + if err := ln.saveCategories(); err != nil { + fmt.Printf("Failed to save notification categories: %v\n", err) + } + + return nil +} + +// RemoveNotificationCategory removes a previously registered NotificationCategory. +func (ln *linuxNotifier) RemoveNotificationCategory(categoryId string) error { + ln.categoriesLock.Lock() + delete(ln.categories, categoryId) + ln.categoriesLock.Unlock() + + if err := ln.saveCategories(); err != nil { + fmt.Printf("Failed to save notification categories: %v\n", err) + } + + return nil +} + +// RemoveAllPendingNotifications attempts to remove all active notifications. +func (ln *linuxNotifier) RemoveAllPendingNotifications() error { + ln.notificationsLock.Lock() + dbusIDs := make([]uint32, 0, len(ln.notifications)) + for id := range ln.notifications { + dbusIDs = append(dbusIDs, id) + } + ln.notificationsLock.Unlock() + + for _, id := range dbusIDs { + ln.closeNotification(id) + } + + return nil +} + +// RemovePendingNotification removes a pending notification. +func (ln *linuxNotifier) RemovePendingNotification(identifier string) error { + var dbusID uint32 + found := false + + ln.notificationsLock.Lock() + for id, notif := range ln.notifications { + if notif.ID == identifier { + dbusID = id + found = true + break + } + } + ln.notificationsLock.Unlock() + + if !found { + return nil + } + + return ln.closeNotification(dbusID) +} + +// RemoveAllDeliveredNotifications functionally equivalent to RemoveAllPendingNotification on Linux. +func (ln *linuxNotifier) RemoveAllDeliveredNotifications() error { + return ln.RemoveAllPendingNotifications() +} + +// RemoveDeliveredNotification functionally equivalent RemovePendingNotification on Linux. +func (ln *linuxNotifier) RemoveDeliveredNotification(identifier string) error { + return ln.RemovePendingNotification(identifier) +} + +// RemoveNotification removes a notification by identifier. +func (ln *linuxNotifier) RemoveNotification(identifier string) error { + return ln.RemovePendingNotification(identifier) +} + +// Helper method to close a notification. +func (ln *linuxNotifier) closeNotification(id uint32) error { + obj := ln.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 (ln *linuxNotifier) 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, ln.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 (ln *linuxNotifier) saveCategories() error { + configDir, err := ln.getConfigDir() + if err != nil { + return err + } + + categoriesFile := filepath.Join(configDir, "notification-categories.json") + + ln.categoriesLock.RLock() + categoriesData, err := json.MarshalIndent(ln.categories, "", " ") + ln.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 (ln *linuxNotifier) loadCategories() error { + configDir, err := ln.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]NotificationCategory) + if err := json.Unmarshal(categoriesData, &categories); err != nil { + return fmt.Errorf("failed to unmarshal notification categories: %w", err) + } + + ln.categoriesLock.Lock() + ln.categories = categories + ln.categoriesLock.Unlock() + + return nil +} + +// Setup signal handling for notification actions. +func (ln *linuxNotifier) setupSignalHandling(ctx context.Context) error { + if err := ln.conn.AddMatchSignal( + dbus.WithMatchInterface(dbusNotificationInterface), + dbus.WithMatchMember("ActionInvoked"), + ); err != nil { + return err + } + + if err := ln.conn.AddMatchSignal( + dbus.WithMatchInterface(dbusNotificationInterface), + dbus.WithMatchMember("NotificationClosed"), + ); err != nil { + return err + } + + c := make(chan *dbus.Signal, 10) + ln.conn.Signal(c) + + go ln.handleSignals(ctx, c) + + return nil +} + +// Handle incoming D-Bus signals. +func (ln *linuxNotifier) 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": + ln.handleActionInvoked(signal) + case dbusNotificationInterface + ".NotificationClosed": + ln.handleNotificationClosed(signal) + } + } + } +} + +// Handle ActionInvoked signal. +func (ln *linuxNotifier) 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 + } + + ln.notificationsLock.Lock() + notification, exists := ln.notifications[dbusID] + if exists { + delete(ln.notifications, dbusID) + } + ln.notificationsLock.Unlock() + + if !exists { + return + } + + appActionID, ok := notification.ActionMap[actionID] + if !ok { + appActionID = actionID + } + + response := NotificationResponse{ + ID: notification.ID, + ActionIdentifier: appActionID, + Title: notification.Title, + Subtitle: notification.Subtitle, + Body: notification.Body, + CategoryID: notification.CategoryID, + UserInfo: notification.Data, + } + + result := NotificationResult{ + Response: response, + } + + if ns := getNotificationService(); ns != nil { + ns.handleNotificationResult(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 (ln *linuxNotifier) 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 + } + + ln.notificationsLock.Lock() + notification, exists := ln.notifications[dbusID] + if exists { + delete(ln.notifications, dbusID) + } + ln.notificationsLock.Unlock() + + if !exists { + return + } + + if reason == 2 { + response := NotificationResponse{ + ID: notification.ID, + ActionIdentifier: DefaultActionIdentifier, + Title: notification.Title, + Subtitle: notification.Subtitle, + Body: notification.Body, + CategoryID: notification.CategoryID, + UserInfo: notification.Data, + } + + result := NotificationResult{ + Response: response, + } + + if ns := getNotificationService(); ns != nil { + ns.handleNotificationResult(result) + } + } +} diff --git a/v3/pkg/services/notifications/notifications_windows.go b/v3/pkg/services/notifications/notifications_windows.go new file mode 100644 index 000000000..b7a16decc --- /dev/null +++ b/v3/pkg/services/notifications/notifications_windows.go @@ -0,0 +1,433 @@ +//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()) +} diff --git a/v3/pkg/w32/icon.go b/v3/pkg/w32/icon.go index 009479323..97d4ad854 100644 --- a/v3/pkg/w32/icon.go +++ b/v3/pkg/w32/icon.go @@ -6,8 +6,11 @@ import ( "bytes" "fmt" "image" + "image/color" "image/draw" "image/png" + "os" + "syscall" "unsafe" ) @@ -90,6 +93,121 @@ func CreateLargeHIconFromImage(fileData []byte) (HICON, error) { return HICON(icon), err } +type ICONINFO struct { + FIcon int32 + XHotspot int32 + YHotspot int32 + HbmMask syscall.Handle + HbmColor syscall.Handle +} + +func SaveHIconAsPNG(hIcon HICON, filePath string) error { + // Load necessary DLLs + user32 := syscall.NewLazyDLL("user32.dll") + gdi32 := syscall.NewLazyDLL("gdi32.dll") + + // Get procedures + getIconInfo := user32.NewProc("GetIconInfo") + getObject := gdi32.NewProc("GetObjectW") + createCompatibleDC := gdi32.NewProc("CreateCompatibleDC") + selectObject := gdi32.NewProc("SelectObject") + getDIBits := gdi32.NewProc("GetDIBits") + deleteObject := gdi32.NewProc("DeleteObject") + deleteDC := gdi32.NewProc("DeleteDC") + + // Get icon info + var iconInfo ICONINFO + ret, _, err := getIconInfo.Call( + uintptr(hIcon), + uintptr(unsafe.Pointer(&iconInfo)), + ) + if ret == 0 { + return err + } + defer deleteObject.Call(uintptr(iconInfo.HbmMask)) + defer deleteObject.Call(uintptr(iconInfo.HbmColor)) + + // Get bitmap info + var bmp BITMAP + ret, _, err = getObject.Call( + uintptr(iconInfo.HbmColor), + unsafe.Sizeof(bmp), + uintptr(unsafe.Pointer(&bmp)), + ) + if ret == 0 { + return err + } + + // Create DC + hdc, _, _ := createCompatibleDC.Call(0) + if hdc == 0 { + return syscall.EINVAL + } + defer deleteDC.Call(hdc) + + // Select bitmap into DC + oldBitmap, _, _ := selectObject.Call(hdc, uintptr(iconInfo.HbmColor)) + defer selectObject.Call(hdc, oldBitmap) + + // 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 = 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 + ret, _, err = getDIBits.Call( + hdc, + uintptr(iconInfo.HbmColor), + 0, + uintptr(bmp.BmHeight), + uintptr(unsafe.Pointer(&bits[0])), + uintptr(unsafe.Pointer(&bi)), + DIB_RGB_COLORS, + ) + if ret == 0 { + return 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 + + // BGRA to RGBA + b := bits[dibIndex] + g := bits[dibIndex+1] + r := bits[dibIndex+2] + a := bits[dibIndex+3] + + // Set pixel in the image + img.Set(x, y, color.RGBA{R: r, G: g, B: b, A: 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 SetWindowIcon(hwnd HWND, icon HICON) { SendMessage(hwnd, WM_SETICON, ICON_SMALL, uintptr(icon)) }