diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml
new file mode 100644
index 000000000..b5e8cfd4d
--- /dev/null
+++ b/.github/workflows/claude-code-review.yml
@@ -0,0 +1,44 @@
+name: Claude Code Review
+
+on:
+ pull_request:
+ types: [opened, synchronize, ready_for_review, reopened]
+ # Optional: Only run on specific file changes
+ # paths:
+ # - "src/**/*.ts"
+ # - "src/**/*.tsx"
+ # - "src/**/*.js"
+ # - "src/**/*.jsx"
+
+jobs:
+ claude-review:
+ # Optional: Filter by PR author
+ # if: |
+ # github.event.pull_request.user.login == 'external-contributor' ||
+ # github.event.pull_request.user.login == 'new-developer' ||
+ # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
+
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: read
+ issues: read
+ id-token: write
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 1
+
+ - name: Run Claude Code Review
+ id: claude-review
+ uses: anthropics/claude-code-action@v1
+ with:
+ claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+ plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
+ plugins: 'code-review@claude-code-plugins'
+ prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
+ # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
+ # or https://code.claude.com/docs/en/cli-reference for available options
+
diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml
new file mode 100644
index 000000000..d300267f1
--- /dev/null
+++ b/.github/workflows/claude.yml
@@ -0,0 +1,50 @@
+name: Claude Code
+
+on:
+ issue_comment:
+ types: [created]
+ pull_request_review_comment:
+ types: [created]
+ issues:
+ types: [opened, assigned]
+ pull_request_review:
+ types: [submitted]
+
+jobs:
+ claude:
+ if: |
+ (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
+ (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
+ (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
+ (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: read
+ issues: read
+ id-token: write
+ actions: read # Required for Claude to read CI results on PRs
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 1
+
+ - name: Run Claude Code
+ id: claude
+ uses: anthropics/claude-code-action@v1
+ with:
+ claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+
+ # This is an optional setting that allows Claude to read CI results on PRs
+ additional_permissions: |
+ actions: read
+
+ # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
+ # prompt: 'Update the pull request description to include a summary of changes.'
+
+ # Optional: Add claude_args to customize behavior and configuration
+ # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
+ # or https://code.claude.com/docs/en/cli-reference for available options
+ # claude_args: '--allowed-tools Bash(gh pr:*)'
+
diff --git a/v2/examples/panic-recovery-test/README.md b/v2/examples/panic-recovery-test/README.md
new file mode 100644
index 000000000..c0a6a7e5a
--- /dev/null
+++ b/v2/examples/panic-recovery-test/README.md
@@ -0,0 +1,76 @@
+# Panic Recovery Test
+
+This example demonstrates the Linux signal handler issue (#3965) and verifies the fix using `runtime.ResetSignalHandlers()`.
+
+## The Problem
+
+On Linux, WebKit installs signal handlers without the `SA_ONSTACK` flag, which prevents Go from recovering panics caused by nil pointer dereferences (SIGSEGV). Without the fix, the application crashes with:
+
+```
+signal 11 received but handler not on signal stack
+fatal error: non-Go code set up signal handler without SA_ONSTACK flag
+```
+
+## The Solution
+
+Call `runtime.ResetSignalHandlers()` immediately before code that might panic:
+
+```go
+import "github.com/wailsapp/wails/v2/pkg/runtime"
+
+go func() {
+ defer func() {
+ if err := recover(); err != nil {
+ log.Printf("Recovered: %v", err)
+ }
+ }()
+ runtime.ResetSignalHandlers()
+ // Code that might panic...
+}()
+```
+
+## How to Reproduce
+
+### Prerequisites
+
+- Linux with WebKit2GTK 4.1 installed
+- Go 1.21+
+- Wails CLI
+
+### Steps
+
+1. Build the example:
+ ```bash
+ cd v2/examples/panic-recovery-test
+ wails build -tags webkit2_41
+ ```
+
+2. Run the application:
+ ```bash
+ ./build/bin/panic-recovery-test
+ ```
+
+3. Wait ~10 seconds (the app auto-calls `Greet` after 5s, then waits another 5s before the nil pointer dereference)
+
+### Expected Result (with fix)
+
+The panic is recovered and you see:
+```
+------------------------------"invalid memory address or nil pointer dereference"
+```
+
+The application continues running.
+
+### Without the fix
+
+Comment out the `runtime.ResetSignalHandlers()` call in `app.go` and rebuild. The application will crash with a fatal signal 11 error.
+
+## Files
+
+- `app.go` - Contains the `Greet` function that demonstrates panic recovery
+- `frontend/src/main.js` - Auto-calls `Greet` after 5 seconds to trigger the test
+
+## Related
+
+- Issue: https://github.com/wailsapp/wails/issues/3965
+- Original fix PR: https://github.com/wailsapp/wails/pull/2152
diff --git a/v2/examples/panic-recovery-test/app.go b/v2/examples/panic-recovery-test/app.go
new file mode 100644
index 000000000..ceb46e8d5
--- /dev/null
+++ b/v2/examples/panic-recovery-test/app.go
@@ -0,0 +1,44 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/wailsapp/wails/v2/pkg/runtime"
+)
+
+// App struct
+type App struct {
+ ctx context.Context
+}
+
+// NewApp creates a new App application struct
+func NewApp() *App {
+ return &App{}
+}
+
+// startup is called when the app starts. The context is saved
+// so we can call the runtime methods
+func (a *App) startup(ctx context.Context) {
+ a.ctx = ctx
+}
+
+// Greet returns a greeting for the given name
+func (a *App) Greet(name string) string {
+ go func() {
+ defer func() {
+ if err := recover(); err != nil {
+ fmt.Printf("------------------------------%#v\n", err)
+ }
+ }()
+ time.Sleep(5 * time.Second)
+ // Fix signal handlers right before potential panic using the Wails runtime
+ runtime.ResetSignalHandlers()
+ // Nil pointer dereference - causes SIGSEGV
+ var t *time.Time
+ fmt.Println(t.Unix())
+ }()
+
+ return fmt.Sprintf("Hello %s, It's show time!", name)
+}
diff --git a/v2/examples/panic-recovery-test/frontend/index.html b/v2/examples/panic-recovery-test/frontend/index.html
new file mode 100644
index 000000000..d7aa4e942
--- /dev/null
+++ b/v2/examples/panic-recovery-test/frontend/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ panic-test
+
+
+
+
+
+
diff --git a/v2/examples/panic-recovery-test/frontend/package.json b/v2/examples/panic-recovery-test/frontend/package.json
new file mode 100644
index 000000000..a1b6f8e1a
--- /dev/null
+++ b/v2/examples/panic-recovery-test/frontend/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "frontend",
+ "private": true,
+ "version": "0.0.0",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "devDependencies": {
+ "vite": "^3.0.7"
+ }
+}
\ No newline at end of file
diff --git a/v2/examples/panic-recovery-test/frontend/src/app.css b/v2/examples/panic-recovery-test/frontend/src/app.css
new file mode 100644
index 000000000..59d06f692
--- /dev/null
+++ b/v2/examples/panic-recovery-test/frontend/src/app.css
@@ -0,0 +1,54 @@
+#logo {
+ display: block;
+ width: 50%;
+ height: 50%;
+ margin: auto;
+ padding: 10% 0 0;
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: 100% 100%;
+ background-origin: content-box;
+}
+
+.result {
+ height: 20px;
+ line-height: 20px;
+ margin: 1.5rem auto;
+}
+
+.input-box .btn {
+ width: 60px;
+ height: 30px;
+ line-height: 30px;
+ border-radius: 3px;
+ border: none;
+ margin: 0 0 0 20px;
+ padding: 0 8px;
+ cursor: pointer;
+}
+
+.input-box .btn:hover {
+ background-image: linear-gradient(to top, #cfd9df 0%, #e2ebf0 100%);
+ color: #333333;
+}
+
+.input-box .input {
+ border: none;
+ border-radius: 3px;
+ outline: none;
+ height: 30px;
+ line-height: 30px;
+ padding: 0 10px;
+ background-color: rgba(240, 240, 240, 1);
+ -webkit-font-smoothing: antialiased;
+}
+
+.input-box .input:hover {
+ border: none;
+ background-color: rgba(255, 255, 255, 1);
+}
+
+.input-box .input:focus {
+ border: none;
+ background-color: rgba(255, 255, 255, 1);
+}
\ No newline at end of file
diff --git a/v2/examples/panic-recovery-test/frontend/src/assets/fonts/OFL.txt b/v2/examples/panic-recovery-test/frontend/src/assets/fonts/OFL.txt
new file mode 100644
index 000000000..9cac04ce8
--- /dev/null
+++ b/v2/examples/panic-recovery-test/frontend/src/assets/fonts/OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com),
+
+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/v2/examples/panic-recovery-test/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2 b/v2/examples/panic-recovery-test/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2
new file mode 100644
index 000000000..2f9cc5964
Binary files /dev/null and b/v2/examples/panic-recovery-test/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2 differ
diff --git a/v2/examples/panic-recovery-test/frontend/src/assets/images/logo-universal.png b/v2/examples/panic-recovery-test/frontend/src/assets/images/logo-universal.png
new file mode 100644
index 000000000..d63303bfa
Binary files /dev/null and b/v2/examples/panic-recovery-test/frontend/src/assets/images/logo-universal.png differ
diff --git a/v2/examples/panic-recovery-test/frontend/src/main.js b/v2/examples/panic-recovery-test/frontend/src/main.js
new file mode 100644
index 000000000..ea5e74fc6
--- /dev/null
+++ b/v2/examples/panic-recovery-test/frontend/src/main.js
@@ -0,0 +1,55 @@
+import './style.css';
+import './app.css';
+
+import logo from './assets/images/logo-universal.png';
+import {Greet} from '../wailsjs/go/main/App';
+
+document.querySelector('#app').innerHTML = `
+
+
Please enter your name below 👇
+
+
+
+
+
+`;
+document.getElementById('logo').src = logo;
+
+let nameElement = document.getElementById("name");
+nameElement.focus();
+let resultElement = document.getElementById("result");
+
+// Setup the greet function
+window.greet = function () {
+ // Get name
+ let name = nameElement.value;
+
+ // Check if the input is empty
+ if (name === "") return;
+
+ // Call App.Greet(name)
+ try {
+ Greet(name)
+ .then((result) => {
+ // Update result with data back from App.Greet()
+ resultElement.innerText = result;
+ })
+ .catch((err) => {
+ console.error(err);
+ });
+ } catch (err) {
+ console.error(err);
+ }
+};
+
+// Auto-call Greet after 5 seconds to trigger the panic test
+setTimeout(() => {
+ console.log("Auto-calling Greet to trigger panic test...");
+ Greet("PanicTest")
+ .then((result) => {
+ resultElement.innerText = result + " (auto-called - panic will occur in 5s)";
+ })
+ .catch((err) => {
+ console.error("Error:", err);
+ });
+}, 5000);
diff --git a/v2/examples/panic-recovery-test/frontend/src/style.css b/v2/examples/panic-recovery-test/frontend/src/style.css
new file mode 100644
index 000000000..3940d6c63
--- /dev/null
+++ b/v2/examples/panic-recovery-test/frontend/src/style.css
@@ -0,0 +1,26 @@
+html {
+ background-color: rgba(27, 38, 54, 1);
+ text-align: center;
+ color: white;
+}
+
+body {
+ margin: 0;
+ color: white;
+ font-family: "Nunito", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
+ "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
+ sans-serif;
+}
+
+@font-face {
+ font-family: "Nunito";
+ font-style: normal;
+ font-weight: 400;
+ src: local(""),
+ url("assets/fonts/nunito-v16-latin-regular.woff2") format("woff2");
+}
+
+#app {
+ height: 100vh;
+ text-align: center;
+}
diff --git a/v2/examples/panic-recovery-test/frontend/wailsjs/go/main/App.d.ts b/v2/examples/panic-recovery-test/frontend/wailsjs/go/main/App.d.ts
new file mode 100755
index 000000000..02a3bb988
--- /dev/null
+++ b/v2/examples/panic-recovery-test/frontend/wailsjs/go/main/App.d.ts
@@ -0,0 +1,4 @@
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+export function Greet(arg1:string):Promise;
diff --git a/v2/examples/panic-recovery-test/frontend/wailsjs/go/main/App.js b/v2/examples/panic-recovery-test/frontend/wailsjs/go/main/App.js
new file mode 100755
index 000000000..c71ae77cb
--- /dev/null
+++ b/v2/examples/panic-recovery-test/frontend/wailsjs/go/main/App.js
@@ -0,0 +1,7 @@
+// @ts-check
+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
+// This file is automatically generated. DO NOT EDIT
+
+export function Greet(arg1) {
+ return window['go']['main']['App']['Greet'](arg1);
+}
diff --git a/v2/examples/panic-recovery-test/frontend/wailsjs/runtime/package.json b/v2/examples/panic-recovery-test/frontend/wailsjs/runtime/package.json
new file mode 100644
index 000000000..1e7c8a5d7
--- /dev/null
+++ b/v2/examples/panic-recovery-test/frontend/wailsjs/runtime/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "@wailsapp/runtime",
+ "version": "2.0.0",
+ "description": "Wails Javascript runtime library",
+ "main": "runtime.js",
+ "types": "runtime.d.ts",
+ "scripts": {
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/wailsapp/wails.git"
+ },
+ "keywords": [
+ "Wails",
+ "Javascript",
+ "Go"
+ ],
+ "author": "Lea Anthony ",
+ "license": "MIT",
+ "bugs": {
+ "url": "https://github.com/wailsapp/wails/issues"
+ },
+ "homepage": "https://github.com/wailsapp/wails#readme"
+}
diff --git a/v2/examples/panic-recovery-test/frontend/wailsjs/runtime/runtime.d.ts b/v2/examples/panic-recovery-test/frontend/wailsjs/runtime/runtime.d.ts
new file mode 100644
index 000000000..4445dac21
--- /dev/null
+++ b/v2/examples/panic-recovery-test/frontend/wailsjs/runtime/runtime.d.ts
@@ -0,0 +1,249 @@
+/*
+ _ __ _ __
+| | / /___ _(_) /____
+| | /| / / __ `/ / / ___/
+| |/ |/ / /_/ / / (__ )
+|__/|__/\__,_/_/_/____/
+The electron alternative for Go
+(c) Lea Anthony 2019-present
+*/
+
+export interface Position {
+ x: number;
+ y: number;
+}
+
+export interface Size {
+ w: number;
+ h: number;
+}
+
+export interface Screen {
+ isCurrent: boolean;
+ isPrimary: boolean;
+ width : number
+ height : number
+}
+
+// Environment information such as platform, buildtype, ...
+export interface EnvironmentInfo {
+ buildType: string;
+ platform: string;
+ arch: string;
+}
+
+// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit)
+// emits the given event. Optional data may be passed with the event.
+// This will trigger any event listeners.
+export function EventsEmit(eventName: string, ...data: any): void;
+
+// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
+export function EventsOn(eventName: string, callback: (...data: any) => void): () => void;
+
+// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
+// sets up a listener for the given event name, but will only trigger a given number times.
+export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void;
+
+// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
+// sets up a listener for the given event name, but will only trigger once.
+export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void;
+
+// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff)
+// unregisters the listener for the given event name.
+export function EventsOff(eventName: string, ...additionalEventNames: string[]): void;
+
+// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
+// unregisters all listeners.
+export function EventsOffAll(): void;
+
+// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
+// logs the given message as a raw message
+export function LogPrint(message: string): void;
+
+// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace)
+// logs the given message at the `trace` log level.
+export function LogTrace(message: string): void;
+
+// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug)
+// logs the given message at the `debug` log level.
+export function LogDebug(message: string): void;
+
+// [LogError](https://wails.io/docs/reference/runtime/log#logerror)
+// logs the given message at the `error` log level.
+export function LogError(message: string): void;
+
+// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal)
+// logs the given message at the `fatal` log level.
+// The application will quit after calling this method.
+export function LogFatal(message: string): void;
+
+// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo)
+// logs the given message at the `info` log level.
+export function LogInfo(message: string): void;
+
+// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning)
+// logs the given message at the `warning` log level.
+export function LogWarning(message: string): void;
+
+// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload)
+// Forces a reload by the main application as well as connected browsers.
+export function WindowReload(): void;
+
+// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp)
+// Reloads the application frontend.
+export function WindowReloadApp(): void;
+
+// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop)
+// Sets the window AlwaysOnTop or not on top.
+export function WindowSetAlwaysOnTop(b: boolean): void;
+
+// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme)
+// *Windows only*
+// Sets window theme to system default (dark/light).
+export function WindowSetSystemDefaultTheme(): void;
+
+// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme)
+// *Windows only*
+// Sets window to light theme.
+export function WindowSetLightTheme(): void;
+
+// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme)
+// *Windows only*
+// Sets window to dark theme.
+export function WindowSetDarkTheme(): void;
+
+// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter)
+// Centers the window on the monitor the window is currently on.
+export function WindowCenter(): void;
+
+// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle)
+// Sets the text in the window title bar.
+export function WindowSetTitle(title: string): void;
+
+// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen)
+// Makes the window full screen.
+export function WindowFullscreen(): void;
+
+// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen)
+// Restores the previous window dimensions and position prior to full screen.
+export function WindowUnfullscreen(): void;
+
+// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen)
+// Returns the state of the window, i.e. whether the window is in full screen mode or not.
+export function WindowIsFullscreen(): Promise;
+
+// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
+// Sets the width and height of the window.
+export function WindowSetSize(width: number, height: number): void;
+
+// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
+// Gets the width and height of the window.
+export function WindowGetSize(): Promise;
+
+// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize)
+// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions.
+// Setting a size of 0,0 will disable this constraint.
+export function WindowSetMaxSize(width: number, height: number): void;
+
+// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize)
+// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions.
+// Setting a size of 0,0 will disable this constraint.
+export function WindowSetMinSize(width: number, height: number): void;
+
+// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition)
+// Sets the window position relative to the monitor the window is currently on.
+export function WindowSetPosition(x: number, y: number): void;
+
+// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition)
+// Gets the window position relative to the monitor the window is currently on.
+export function WindowGetPosition(): Promise;
+
+// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide)
+// Hides the window.
+export function WindowHide(): void;
+
+// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow)
+// Shows the window, if it is currently hidden.
+export function WindowShow(): void;
+
+// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise)
+// Maximises the window to fill the screen.
+export function WindowMaximise(): void;
+
+// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise)
+// Toggles between Maximised and UnMaximised.
+export function WindowToggleMaximise(): void;
+
+// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise)
+// Restores the window to the dimensions and position prior to maximising.
+export function WindowUnmaximise(): void;
+
+// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised)
+// Returns the state of the window, i.e. whether the window is maximised or not.
+export function WindowIsMaximised(): Promise;
+
+// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
+// Minimises the window.
+export function WindowMinimise(): void;
+
+// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise)
+// Restores the window to the dimensions and position prior to minimising.
+export function WindowUnminimise(): void;
+
+// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised)
+// Returns the state of the window, i.e. whether the window is minimised or not.
+export function WindowIsMinimised(): Promise;
+
+// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal)
+// Returns the state of the window, i.e. whether the window is normal or not.
+export function WindowIsNormal(): Promise;
+
+// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
+// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
+export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
+
+// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall)
+// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system.
+export function ScreenGetAll(): Promise;
+
+// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl)
+// Opens the given URL in the system browser.
+export function BrowserOpenURL(url: string): void;
+
+// [Environment](https://wails.io/docs/reference/runtime/intro#environment)
+// Returns information about the environment
+export function Environment(): Promise;
+
+// [Quit](https://wails.io/docs/reference/runtime/intro#quit)
+// Quits the application.
+export function Quit(): void;
+
+// [Hide](https://wails.io/docs/reference/runtime/intro#hide)
+// Hides the application.
+export function Hide(): void;
+
+// [Show](https://wails.io/docs/reference/runtime/intro#show)
+// Shows the application.
+export function Show(): void;
+
+// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext)
+// Returns the current text stored on clipboard
+export function ClipboardGetText(): Promise;
+
+// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
+// Sets a text on the clipboard
+export function ClipboardSetText(text: string): Promise;
+
+// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop)
+// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
+export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void
+
+// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff)
+// OnFileDropOff removes the drag and drop listeners and handlers.
+export function OnFileDropOff() :void
+
+// Check if the file path resolver is available
+export function CanResolveFilePaths(): boolean;
+
+// Resolves file paths for an array of files
+export function ResolveFilePaths(files: File[]): void
\ No newline at end of file
diff --git a/v2/examples/panic-recovery-test/frontend/wailsjs/runtime/runtime.js b/v2/examples/panic-recovery-test/frontend/wailsjs/runtime/runtime.js
new file mode 100644
index 000000000..7cb89d750
--- /dev/null
+++ b/v2/examples/panic-recovery-test/frontend/wailsjs/runtime/runtime.js
@@ -0,0 +1,242 @@
+/*
+ _ __ _ __
+| | / /___ _(_) /____
+| | /| / / __ `/ / / ___/
+| |/ |/ / /_/ / / (__ )
+|__/|__/\__,_/_/_/____/
+The electron alternative for Go
+(c) Lea Anthony 2019-present
+*/
+
+export function LogPrint(message) {
+ window.runtime.LogPrint(message);
+}
+
+export function LogTrace(message) {
+ window.runtime.LogTrace(message);
+}
+
+export function LogDebug(message) {
+ window.runtime.LogDebug(message);
+}
+
+export function LogInfo(message) {
+ window.runtime.LogInfo(message);
+}
+
+export function LogWarning(message) {
+ window.runtime.LogWarning(message);
+}
+
+export function LogError(message) {
+ window.runtime.LogError(message);
+}
+
+export function LogFatal(message) {
+ window.runtime.LogFatal(message);
+}
+
+export function EventsOnMultiple(eventName, callback, maxCallbacks) {
+ return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
+}
+
+export function EventsOn(eventName, callback) {
+ return EventsOnMultiple(eventName, callback, -1);
+}
+
+export function EventsOff(eventName, ...additionalEventNames) {
+ return window.runtime.EventsOff(eventName, ...additionalEventNames);
+}
+
+export function EventsOffAll() {
+ return window.runtime.EventsOffAll();
+}
+
+export function EventsOnce(eventName, callback) {
+ return EventsOnMultiple(eventName, callback, 1);
+}
+
+export function EventsEmit(eventName) {
+ let args = [eventName].slice.call(arguments);
+ return window.runtime.EventsEmit.apply(null, args);
+}
+
+export function WindowReload() {
+ window.runtime.WindowReload();
+}
+
+export function WindowReloadApp() {
+ window.runtime.WindowReloadApp();
+}
+
+export function WindowSetAlwaysOnTop(b) {
+ window.runtime.WindowSetAlwaysOnTop(b);
+}
+
+export function WindowSetSystemDefaultTheme() {
+ window.runtime.WindowSetSystemDefaultTheme();
+}
+
+export function WindowSetLightTheme() {
+ window.runtime.WindowSetLightTheme();
+}
+
+export function WindowSetDarkTheme() {
+ window.runtime.WindowSetDarkTheme();
+}
+
+export function WindowCenter() {
+ window.runtime.WindowCenter();
+}
+
+export function WindowSetTitle(title) {
+ window.runtime.WindowSetTitle(title);
+}
+
+export function WindowFullscreen() {
+ window.runtime.WindowFullscreen();
+}
+
+export function WindowUnfullscreen() {
+ window.runtime.WindowUnfullscreen();
+}
+
+export function WindowIsFullscreen() {
+ return window.runtime.WindowIsFullscreen();
+}
+
+export function WindowGetSize() {
+ return window.runtime.WindowGetSize();
+}
+
+export function WindowSetSize(width, height) {
+ window.runtime.WindowSetSize(width, height);
+}
+
+export function WindowSetMaxSize(width, height) {
+ window.runtime.WindowSetMaxSize(width, height);
+}
+
+export function WindowSetMinSize(width, height) {
+ window.runtime.WindowSetMinSize(width, height);
+}
+
+export function WindowSetPosition(x, y) {
+ window.runtime.WindowSetPosition(x, y);
+}
+
+export function WindowGetPosition() {
+ return window.runtime.WindowGetPosition();
+}
+
+export function WindowHide() {
+ window.runtime.WindowHide();
+}
+
+export function WindowShow() {
+ window.runtime.WindowShow();
+}
+
+export function WindowMaximise() {
+ window.runtime.WindowMaximise();
+}
+
+export function WindowToggleMaximise() {
+ window.runtime.WindowToggleMaximise();
+}
+
+export function WindowUnmaximise() {
+ window.runtime.WindowUnmaximise();
+}
+
+export function WindowIsMaximised() {
+ return window.runtime.WindowIsMaximised();
+}
+
+export function WindowMinimise() {
+ window.runtime.WindowMinimise();
+}
+
+export function WindowUnminimise() {
+ window.runtime.WindowUnminimise();
+}
+
+export function WindowSetBackgroundColour(R, G, B, A) {
+ window.runtime.WindowSetBackgroundColour(R, G, B, A);
+}
+
+export function ScreenGetAll() {
+ return window.runtime.ScreenGetAll();
+}
+
+export function WindowIsMinimised() {
+ return window.runtime.WindowIsMinimised();
+}
+
+export function WindowIsNormal() {
+ return window.runtime.WindowIsNormal();
+}
+
+export function BrowserOpenURL(url) {
+ window.runtime.BrowserOpenURL(url);
+}
+
+export function Environment() {
+ return window.runtime.Environment();
+}
+
+export function Quit() {
+ window.runtime.Quit();
+}
+
+export function Hide() {
+ window.runtime.Hide();
+}
+
+export function Show() {
+ window.runtime.Show();
+}
+
+export function ClipboardGetText() {
+ return window.runtime.ClipboardGetText();
+}
+
+export function ClipboardSetText(text) {
+ return window.runtime.ClipboardSetText(text);
+}
+
+/**
+ * Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
+ *
+ * @export
+ * @callback OnFileDropCallback
+ * @param {number} x - x coordinate of the drop
+ * @param {number} y - y coordinate of the drop
+ * @param {string[]} paths - A list of file paths.
+ */
+
+/**
+ * OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
+ *
+ * @export
+ * @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
+ * @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target)
+ */
+export function OnFileDrop(callback, useDropTarget) {
+ return window.runtime.OnFileDrop(callback, useDropTarget);
+}
+
+/**
+ * OnFileDropOff removes the drag and drop listeners and handlers.
+ */
+export function OnFileDropOff() {
+ return window.runtime.OnFileDropOff();
+}
+
+export function CanResolveFilePaths() {
+ return window.runtime.CanResolveFilePaths();
+}
+
+export function ResolveFilePaths(files) {
+ return window.runtime.ResolveFilePaths(files);
+}
\ No newline at end of file
diff --git a/v2/examples/panic-recovery-test/go.mod b/v2/examples/panic-recovery-test/go.mod
new file mode 100644
index 000000000..026042cbf
--- /dev/null
+++ b/v2/examples/panic-recovery-test/go.mod
@@ -0,0 +1,5 @@
+module panic-recovery-test
+
+go 1.21
+
+require github.com/wailsapp/wails/v2 v2.11.0
diff --git a/v2/examples/panic-recovery-test/main.go b/v2/examples/panic-recovery-test/main.go
new file mode 100644
index 000000000..f6a38e86c
--- /dev/null
+++ b/v2/examples/panic-recovery-test/main.go
@@ -0,0 +1,36 @@
+package main
+
+import (
+ "embed"
+
+ "github.com/wailsapp/wails/v2"
+ "github.com/wailsapp/wails/v2/pkg/options"
+ "github.com/wailsapp/wails/v2/pkg/options/assetserver"
+)
+
+//go:embed all:frontend/dist
+var assets embed.FS
+
+func main() {
+ // Create an instance of the app structure
+ app := NewApp()
+
+ // Create application with options
+ err := wails.Run(&options.App{
+ Title: "panic-test",
+ Width: 1024,
+ Height: 768,
+ AssetServer: &assetserver.Options{
+ Assets: assets,
+ },
+ BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
+ OnStartup: app.startup,
+ Bind: []interface{}{
+ app,
+ },
+ })
+
+ if err != nil {
+ println("Error:", err.Error())
+ }
+}
diff --git a/v2/examples/panic-recovery-test/wails.json b/v2/examples/panic-recovery-test/wails.json
new file mode 100644
index 000000000..56770f091
--- /dev/null
+++ b/v2/examples/panic-recovery-test/wails.json
@@ -0,0 +1,13 @@
+{
+ "$schema": "https://wails.io/schemas/config.v2.json",
+ "name": "panic-recovery-test",
+ "outputfilename": "panic-recovery-test",
+ "frontend:install": "npm install",
+ "frontend:build": "npm run build",
+ "frontend:dev:watcher": "npm run dev",
+ "frontend:dev:serverUrl": "auto",
+ "author": {
+ "name": "Lea Anthony",
+ "email": "lea.anthony@gmail.com"
+ }
+}
diff --git a/v2/go.mod b/v2/go.mod
index 1a40badd2..f1287bde7 100644
--- a/v2/go.mod
+++ b/v2/go.mod
@@ -17,7 +17,7 @@ require (
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/jackmordaunt/icns v1.0.0
- github.com/jaypipes/ghw v0.13.0
+ github.com/jaypipes/ghw v0.21.3
github.com/labstack/echo/v4 v4.13.3
github.com/labstack/gommon v0.4.2
github.com/leaanthony/clir v1.3.0
@@ -51,9 +51,9 @@ require (
atomicgo.dev/keyboard v0.2.9 // indirect
atomicgo.dev/schedule v0.1.0 // indirect
dario.cat/mergo v1.0.0 // indirect
+ git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/ProtonMail/go-crypto v1.1.5 // indirect
- github.com/StackExchange/wmi v1.2.1 // indirect
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
@@ -72,7 +72,7 @@ require (
github.com/gorilla/css v1.0.1 // indirect
github.com/itchyny/gojq v0.12.13 // indirect
github.com/itchyny/timefmt-go v0.1.5 // indirect
- github.com/jaypipes/pcidb v1.0.1 // indirect
+ github.com/jaypipes/pcidb v1.1.1 // 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
@@ -82,7 +82,6 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
- github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
@@ -101,6 +100,7 @@ require (
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.4 // indirect
github.com/yuin/goldmark-emoji v1.0.3 // indirect
+ github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/image v0.12.0 // indirect
golang.org/x/sync v0.11.0 // indirect
@@ -108,6 +108,6 @@ require (
golang.org/x/text v0.22.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
- howett.net/plist v1.0.0 // indirect
+ howett.net/plist v1.0.2-0.20250314012144-ee69052608d9 // indirect
mvdan.cc/sh/v3 v3.7.0 // indirect
)
diff --git a/v2/go.sum b/v2/go.sum
index 53e56707e..2cfe9f7ab 100644
--- a/v2/go.sum
+++ b/v2/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.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
+git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
+git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/MarvinJWendt/testza v0.1.0/go.mod h1:7AxNvlfeHP7Z/hDQ5JtE3OKYT3XFUeLCDE2DQninSqs=
github.com/MarvinJWendt/testza v0.2.1/go.mod h1:God7bhG8n6uQxwdScay+gjm9/LnO4D3kkcZX4hv9Rp8=
github.com/MarvinJWendt/testza v0.2.8/go.mod h1:nwIcjmr0Zz+Rcwfh3/4UhBp7ePKVhuBExvZqnKYWlII=
@@ -24,8 +26,6 @@ github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migc
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
github.com/ProtonMail/go-crypto v1.1.5 h1:eoAQfK2dwL+tFSFpr7TbOaPNUbPiJj4fLYwwGE1FQO4=
github.com/ProtonMail/go-crypto v1.1.5/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
-github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA=
-github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8=
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8=
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo=
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
@@ -88,7 +88,7 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.13.2 h1:7O7xvsK7K+rZPKW6AQR1YyNhfywkv7B8/FsP3ki6Zv0=
github.com/go-git/go-git/v5 v5.13.2/go.mod h1:hWdW5P4YZRjmpGHwRH2v3zkWcNl6HeXaXQEMGb3NJ9A=
-github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
+github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
@@ -117,10 +117,10 @@ github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm
github.com/itchyny/timefmt-go v0.1.5/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8=
github.com/jackmordaunt/icns v1.0.0 h1:RYSxplerf/l/DUd09AHtITwckkv/mqjVv4DjYdPmAMQ=
github.com/jackmordaunt/icns v1.0.0/go.mod h1:7TTQVEuGzVVfOPPlLNHJIkzA6CoV7aH1Dv9dW351oOo=
-github.com/jaypipes/ghw v0.13.0 h1:log8MXuB8hzTNnSktqpXMHc0c/2k/WgjOMSUtnI1RV4=
-github.com/jaypipes/ghw v0.13.0/go.mod h1:In8SsaDqlb1oTyrbmTC14uy+fbBMvp+xdqX51MidlD8=
-github.com/jaypipes/pcidb v1.0.1 h1:WB2zh27T3nwg8AE8ei81sNRb9yWBii3JGNJtT7K9Oic=
-github.com/jaypipes/pcidb v1.0.1/go.mod h1:6xYUz/yYEyOkIkUt2t2J2folIuZ4Yg6uByCGFXMCeE4=
+github.com/jaypipes/ghw v0.21.3 h1:v5mUHM+RN854Vqmk49Uh213jyUA4+8uqaRajlYESsh8=
+github.com/jaypipes/ghw v0.21.3/go.mod h1:GPrvwbtPoxYUenr74+nAnWbardIZq600vJDD5HnPsPE=
+github.com/jaypipes/pcidb v1.1.1 h1:QmPhpsbmmnCwZmHeYAATxEaoRuiMAJusKYkUncMC0ro=
+github.com/jaypipes/pcidb v1.1.1/go.mod h1:x27LT2krrUgjf875KxQXKB0Ha/YXLdZRVmw6hH0G7g8=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
@@ -178,8 +178,6 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
-github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
-github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg=
@@ -263,6 +261,8 @@ github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg=
github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark-emoji v1.0.3 h1:aLRkLHOuBR2czCY4R8olwMjID+tENfhyFDMCRhbIQY4=
github.com/yuin/goldmark-emoji v1.0.3/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
+github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
+github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
@@ -340,7 +340,6 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
-gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
@@ -348,7 +347,7 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
-howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
+howett.net/plist v1.0.2-0.20250314012144-ee69052608d9 h1:eeH1AIcPvSc0Z25ThsYF+Xoqbn0CI/YnXVYoTLFdGQw=
+howett.net/plist v1.0.2-0.20250314012144-ee69052608d9/go.mod h1:fyFX5Hj5tP1Mpk8obqA9MZgXT416Q5711SDT7dQLTLk=
mvdan.cc/sh/v3 v3.7.0 h1:lSTjdP/1xsddtaKfGg7Myu7DnlHItd3/M2tomOcNNBg=
mvdan.cc/sh/v3 v3.7.0/go.mod h1:K2gwkaesF/D7av7Kxl0HbF5kGOd2ArupNTX3X44+8l8=
diff --git a/v2/internal/frontend/desktop/darwin/Application.h b/v2/internal/frontend/desktop/darwin/Application.h
index 4d8bbd37b..c3cd8075a 100644
--- a/v2/internal/frontend/desktop/darwin/Application.h
+++ b/v2/internal/frontend/desktop/darwin/Application.h
@@ -69,6 +69,21 @@ void UpdateMenuItem(void* nsmenuitem, int checked);
void RunMainLoop(void);
void ReleaseContext(void *inctx);
+/* Notifications */
+bool IsNotificationAvailable(void *inctx);
+bool CheckBundleIdentifier(void *inctx);
+bool EnsureDelegateInitialized(void *inctx);
+void RequestNotificationAuthorization(void *inctx, int channelID);
+void CheckNotificationAuthorization(void *inctx, int channelID);
+void SendNotification(void *inctx, int channelID, const char *identifier, const char *title, const char *subtitle, const char *body, const char *data_json);
+void SendNotificationWithActions(void *inctx, int channelID, const char *identifier, const char *title, const char *subtitle, const char *body, const char *categoryId, const char *actions_json);
+void RegisterNotificationCategory(void *inctx, int channelID, const char *categoryId, const char *actions_json, bool hasReplyField, const char *replyPlaceholder, const char *replyButtonTitle);
+void RemoveNotificationCategory(void *inctx, int channelID, const char *categoryId);
+void RemoveAllPendingNotifications(void *inctx);
+void RemovePendingNotification(void *inctx, const char *identifier);
+void RemoveAllDeliveredNotifications(void *inctx);
+void RemoveDeliveredNotification(void *inctx, const char *identifier);
+
NSString* safeInit(const char* input);
#endif /* Application_h */
diff --git a/v2/internal/frontend/desktop/darwin/Application.m b/v2/internal/frontend/desktop/darwin/Application.m
index 38d349c2c..38b2f35ef 100644
--- a/v2/internal/frontend/desktop/darwin/Application.m
+++ b/v2/internal/frontend/desktop/darwin/Application.m
@@ -367,6 +367,74 @@ void AppendSeparator(void* inMenu) {
}
+bool IsNotificationAvailable(void *inctx) {
+ WailsContext *ctx = (__bridge WailsContext*)inctx;
+ return [ctx IsNotificationAvailable];
+}
+
+bool CheckBundleIdentifier(void *inctx) {
+ WailsContext *ctx = (__bridge WailsContext*)inctx;
+ return [ctx CheckBundleIdentifier];
+}
+
+bool EnsureDelegateInitialized(void *inctx) {
+ WailsContext *ctx = (__bridge WailsContext*)inctx;
+ return [ctx EnsureDelegateInitialized];
+}
+
+void RequestNotificationAuthorization(void *inctx, int channelID) {
+ WailsContext *ctx = (__bridge WailsContext*)inctx;
+ [ctx RequestNotificationAuthorization:channelID];
+}
+
+void CheckNotificationAuthorization(void *inctx, int channelID) {
+ WailsContext *ctx = (__bridge WailsContext*)inctx;
+ [ctx CheckNotificationAuthorization:channelID];
+}
+
+void SendNotification(void *inctx, int channelID, const char *identifier, const char *title, const char *subtitle, const char *body, const char *data_json) {
+ WailsContext *ctx = (__bridge WailsContext*)inctx;
+ [ctx SendNotification:channelID :identifier :title :subtitle :body :data_json];
+}
+
+void SendNotificationWithActions(void *inctx, int channelID, const char *identifier, const char *title, const char *subtitle, const char *body, const char *categoryId, const char *actions_json) {
+ WailsContext *ctx = (__bridge WailsContext*)inctx;
+
+ [ctx SendNotificationWithActions:channelID :identifier :title :subtitle :body :categoryId :actions_json];
+}
+
+void RegisterNotificationCategory(void *inctx, int channelID, const char *categoryId, const char *actions_json, bool hasReplyField, const char *replyPlaceholder, const char *replyButtonTitle) {
+ WailsContext *ctx = (__bridge WailsContext*)inctx;
+
+ [ctx RegisterNotificationCategory:channelID :categoryId :actions_json :hasReplyField :replyPlaceholder :replyButtonTitle];
+}
+
+void RemoveNotificationCategory(void *inctx, int channelID, const char *categoryId) {
+ WailsContext *ctx = (__bridge WailsContext*)inctx;
+
+ [ctx RemoveNotificationCategory:channelID :categoryId];
+}
+
+void RemoveAllPendingNotifications(void *inctx) {
+ WailsContext *ctx = (__bridge WailsContext*)inctx;
+ [ctx RemoveAllPendingNotifications];
+}
+
+void RemovePendingNotification(void *inctx, const char *identifier) {
+ WailsContext *ctx = (__bridge WailsContext*)inctx;
+ [ctx RemovePendingNotification:identifier];
+}
+
+void RemoveAllDeliveredNotifications(void *inctx) {
+ WailsContext *ctx = (__bridge WailsContext*)inctx;
+ [ctx RemoveAllDeliveredNotifications];
+}
+
+void RemoveDeliveredNotification(void *inctx, const char *identifier) {
+ WailsContext *ctx = (__bridge WailsContext*)inctx;
+ [ctx RemoveDeliveredNotification:identifier];
+}
+
void Run(void *inctx, const char* url) {
WailsContext *ctx = (__bridge WailsContext*) inctx;
diff --git a/v2/internal/frontend/desktop/darwin/WailsContext.h b/v2/internal/frontend/desktop/darwin/WailsContext.h
index 2ec6d8707..aafc3a1d4 100644
--- a/v2/internal/frontend/desktop/darwin/WailsContext.h
+++ b/v2/internal/frontend/desktop/darwin/WailsContext.h
@@ -92,10 +92,24 @@ struct Preferences {
- (void) ShowApplication;
- (void) Quit;
--(void) MessageDialog :(NSString*)dialogType :(NSString*)title :(NSString*)message :(NSString*)button1 :(NSString*)button2 :(NSString*)button3 :(NSString*)button4 :(NSString*)defaultButton :(NSString*)cancelButton :(void*)iconData :(int)iconDataLength;
+- (void) MessageDialog :(NSString*)dialogType :(NSString*)title :(NSString*)message :(NSString*)button1 :(NSString*)button2 :(NSString*)button3 :(NSString*)button4 :(NSString*)defaultButton :(NSString*)cancelButton :(void*)iconData :(int)iconDataLength;
- (void) OpenFileDialog :(NSString*)title :(NSString*)defaultFilename :(NSString*)defaultDirectory :(bool)allowDirectories :(bool)allowFiles :(bool)canCreateDirectories :(bool)treatPackagesAsDirectories :(bool)resolveAliases :(bool)showHiddenFiles :(bool)allowMultipleSelection :(NSString*)filters;
- (void) SaveFileDialog :(NSString*)title :(NSString*)defaultFilename :(NSString*)defaultDirectory :(bool)canCreateDirectories :(bool)treatPackagesAsDirectories :(bool)showHiddenFiles :(NSString*)filters;
+- (bool) IsNotificationAvailable;
+- (bool) CheckBundleIdentifier;
+- (bool) EnsureDelegateInitialized;
+- (void) RequestNotificationAuthorization:(int)channelID;
+- (void) CheckNotificationAuthorization:(int)channelID;
+- (void) SendNotification:(int)channelID :(const char *)identifier :(const char *)title :(const char *)subtitle :(const char *)body :(const char *)dataJSON;
+- (void) SendNotificationWithActions:(int)channelID :(const char *)identifier :(const char *)title :(const char *)subtitle :(const char *)body :(const char *)categoryId :(const char *)actionsJSON;
+- (void) RegisterNotificationCategory:(int)channelID :(const char *)categoryId :(const char *)actionsJSON :(bool)hasReplyField :(const char *)replyPlaceholder :(const char *)replyButtonTitle;
+- (void) RemoveNotificationCategory:(int)channelID :(const char *)categoryId;
+- (void) RemoveAllPendingNotifications;
+- (void) RemovePendingNotification:(const char *)identifier;
+- (void) RemoveAllDeliveredNotifications;
+- (void) RemoveDeliveredNotification:(const char *)identifier;
+
- (void) loadRequest:(NSString*)url;
- (void) ExecJS:(NSString*)script;
- (NSScreen*) getCurrentScreen;
diff --git a/v2/internal/frontend/desktop/darwin/WailsContext.m b/v2/internal/frontend/desktop/darwin/WailsContext.m
index 7c9660d54..51993eda2 100644
--- a/v2/internal/frontend/desktop/darwin/WailsContext.m
+++ b/v2/internal/frontend/desktop/darwin/WailsContext.m
@@ -5,6 +5,7 @@
// Created by Lea Anthony on 10/10/21.
//
+#include "Application.h"
#import
#import
#import "WailsContext.h"
@@ -36,6 +37,14 @@ typedef void (^schemeTaskCaller)(id);
@end
+// Notifications
+#if __MAC_OS_X_VERSION_MAX_ALLOWED >= 101400
+#import
+#endif
+
+extern void captureResult(int channelID, bool success, const char* error);
+extern void didReceiveNotificationResponse(const char *jsonPayload, const char* error);
+
@implementation WailsContext
- (void) SetSize:(int)width :(int)height {
@@ -723,6 +732,357 @@ typedef void (^schemeTaskCaller)(id);
}
+/***** Notifications ******/
+- (bool) IsNotificationAvailable {
+ if (@available(macOS 10.14, *)) {
+ return YES;
+ } else {
+ return NO;
+ }
+}
+
+- (bool) CheckBundleIdentifier {
+ NSBundle *main = [NSBundle mainBundle];
+ if (main.bundleIdentifier == nil) {
+ return NO;
+ }
+ return YES;
+}
+
+- (void)userNotificationCenter:(UNUserNotificationCenter *)center
+ willPresentNotification:(UNNotification *)notification
+ withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler API_AVAILABLE(macos(10.14)) {
+ UNNotificationPresentationOptions options = UNNotificationPresentationOptionSound;
+
+ if (@available(macOS 11.0, *)) {
+ // These options are only available in macOS 11.0+
+ options = UNNotificationPresentationOptionList |
+ UNNotificationPresentationOptionBanner |
+ UNNotificationPresentationOptionSound;
+ }
+
+ completionHandler(options);
+}
+
+- (void)userNotificationCenter:(UNUserNotificationCenter *)center
+didReceiveNotificationResponse:(UNNotificationResponse *)response
+ withCompletionHandler:(void (^)(void))completionHandler API_AVAILABLE(macos(10.14)) {
+
+ NSMutableDictionary *payload = [NSMutableDictionary dictionary];
+
+ [payload setObject:response.notification.request.identifier forKey:@"id"];
+ [payload setObject:response.actionIdentifier forKey:@"actionIdentifier"];
+ [payload setObject:response.notification.request.content.title ?: @"" forKey:@"title"];
+ [payload setObject:response.notification.request.content.body ?: @"" forKey:@"body"];
+
+ if (response.notification.request.content.categoryIdentifier) {
+ [payload setObject:response.notification.request.content.categoryIdentifier forKey:@"categoryId"];
+ }
+
+ if (response.notification.request.content.subtitle) {
+ [payload setObject:response.notification.request.content.subtitle forKey:@"subtitle"];
+ }
+
+ if (response.notification.request.content.userInfo) {
+ [payload setObject:response.notification.request.content.userInfo forKey:@"userInfo"];
+ }
+
+ if ([response isKindOfClass:[UNTextInputNotificationResponse class]]) {
+ UNTextInputNotificationResponse *textResponse = (UNTextInputNotificationResponse *)response;
+ [payload setObject:textResponse.userText forKey:@"userText"];
+ }
+
+ NSError *error = nil;
+ NSData *jsonData = [NSJSONSerialization dataWithJSONObject:payload options:0 error:&error];
+ if (error) {
+ NSString *errorMsg = [NSString stringWithFormat:@"Error: %@", [error localizedDescription]];
+ didReceiveNotificationResponse(NULL, [errorMsg UTF8String]);
+ } else {
+ NSString *jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding];
+ didReceiveNotificationResponse([jsonString UTF8String], NULL);
+ }
+
+ completionHandler();
+}
+
+- (bool) EnsureDelegateInitialized {
+ if (@available(macOS 10.14, *)) {
+ UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
+ center.delegate = (id)self;
+ return YES;
+ }
+ return NO;
+}
+
+- (void) RequestNotificationAuthorization :(int)channelID {
+ if (@available(macOS 10.14, *)) {
+ if (![self EnsureDelegateInitialized]) {
+ NSString *errorMsg = @"Notification delegate has been lost. Reinitialize the notification service.";
+ captureResult(channelID, false, [errorMsg UTF8String]);
+ return;
+ }
+
+ UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
+ UNAuthorizationOptions options = UNAuthorizationOptionAlert | UNAuthorizationOptionSound | UNAuthorizationOptionBadge;
+
+ [center requestAuthorizationWithOptions:options completionHandler:^(BOOL granted, NSError * _Nullable error) {
+ if (error) {
+ NSString *errorMsg = [NSString stringWithFormat:@"Error: %@", [error localizedDescription]];
+ captureResult(channelID, false, [errorMsg UTF8String]);
+ } else {
+ captureResult(channelID, granted, NULL);
+ }
+ }];
+ } else {
+ captureResult(channelID, false, "Notifications not available on macOS versions prior to 10.14");
+ }
+}
+
+- (void) CheckNotificationAuthorization :(int) channelID {
+ if (@available(macOS 10.14, *)) {
+ if (![self EnsureDelegateInitialized]) {
+ NSString *errorMsg = @"Notification delegate has been lost. Reinitialize the notification service.";
+ captureResult(channelID, false, [errorMsg UTF8String]);
+ return;
+ }
+
+ UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
+ [center getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings *settings) {
+ BOOL isAuthorized = (settings.authorizationStatus == UNAuthorizationStatusAuthorized);
+ captureResult(channelID, isAuthorized, NULL);
+ }];
+ } else {
+ captureResult(channelID, false, "Notifications not available on macOS versions prior to 10.14");
+ }
+}
+
+- (UNMutableNotificationContent *)createNotificationContent:(const char *)title subtitle:(const char *)subtitle body:(const char *)body dataJSON:(const char *)dataJSON error:(NSError **)contentError API_AVAILABLE(macos(10.14)) {
+ if (title == NULL) title = "";
+ if (body == NULL) body = "";
+
+ NSString *nsTitle = [NSString stringWithUTF8String:title];
+ NSString *nsSubtitle = subtitle ? [NSString stringWithUTF8String:subtitle] : @"";
+ NSString *nsBody = [NSString stringWithUTF8String:body];
+
+ UNMutableNotificationContent *content = [[[UNMutableNotificationContent alloc] init] autorelease];
+ content.title = nsTitle;
+ if (![nsSubtitle isEqualToString:@""]) {
+ content.subtitle = nsSubtitle;
+ }
+ content.body = nsBody;
+ content.sound = [UNNotificationSound defaultSound];
+
+ // Parse JSON data if provided
+ if (dataJSON) {
+ NSString *dataJsonStr = [NSString stringWithUTF8String:dataJSON];
+ NSData *jsonData = [dataJsonStr dataUsingEncoding:NSUTF8StringEncoding];
+ NSError *error = nil;
+ NSDictionary *parsedData = [NSJSONSerialization JSONObjectWithData:jsonData options:0 error:&error];
+ if (!error && parsedData) {
+ content.userInfo = parsedData;
+ } else if (error) {
+ if (contentError) *contentError = error;
+ }
+ }
+
+ return content;
+}
+
+- (void) sendNotificationWithRequest:(UNNotificationRequest *)request channelID:(int)channelID API_AVAILABLE(macos(10.14)) {
+ UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
+ [center addNotificationRequest:request withCompletionHandler:^(NSError * _Nullable error) {
+ if (error) {
+ NSString *errorMsg = [NSString stringWithFormat:@"Error: %@", [error localizedDescription]];
+ captureResult(channelID, false, [errorMsg UTF8String]);
+ } else {
+ captureResult(channelID, true, NULL);
+ }
+ }];
+}
+
+- (void) SendNotification:(int)channelID :(const char *)identifier :(const char *)title :(const char *)subtitle :(const char *)body :(const char *)dataJSON API_AVAILABLE(macos(10.14)) {
+ if (![self EnsureDelegateInitialized]) {
+ NSString *errorMsg = @"Notification delegate has been lost. Reinitialize the notification service.";
+ captureResult(channelID, false, [errorMsg UTF8String]);
+ return;
+ }
+
+ NSString *nsIdentifier = [NSString stringWithUTF8String:identifier];
+
+ NSError *contentError = nil;
+ UNMutableNotificationContent *content = [self createNotificationContent:title subtitle:subtitle body:body dataJSON:dataJSON error:&contentError];
+ if (contentError) {
+ NSString *errorMsg = [NSString stringWithFormat:@"Error: %@", [contentError localizedDescription]];
+ captureResult(channelID, false, [errorMsg UTF8String]);
+ return;
+ }
+
+ UNTimeIntervalNotificationTrigger *trigger = nil;
+ UNNotificationRequest *request = [UNNotificationRequest requestWithIdentifier:nsIdentifier content:content trigger:trigger];
+
+ [self sendNotificationWithRequest:request channelID:channelID];
+}
+
+- (void) SendNotificationWithActions:(int)channelID :(const char *)identifier :(const char *)title :(const char *)subtitle :(const char *)body :(const char *)categoryId :(const char *)dataJSON API_AVAILABLE(macos(10.14)) {
+ if (![self EnsureDelegateInitialized]) {
+ NSString *errorMsg = @"Notification delegate has been lost. Reinitialize the notification service.";
+ captureResult(channelID, false, [errorMsg UTF8String]);
+ return;
+ }
+
+ NSString *nsIdentifier = [NSString stringWithUTF8String:identifier];
+ NSString *nsCategoryId = [NSString stringWithUTF8String:categoryId];
+
+ NSError *contentError = nil;
+ UNMutableNotificationContent *content = [self createNotificationContent:title subtitle:subtitle body:body dataJSON:dataJSON error:&contentError];
+ if (contentError) {
+ NSString *errorMsg = [NSString stringWithFormat:@"Error: %@", [contentError localizedDescription]];
+ captureResult(channelID, false, [errorMsg UTF8String]);
+ return;
+ }
+
+ content.categoryIdentifier = nsCategoryId;
+
+ UNTimeIntervalNotificationTrigger *trigger = nil;
+ UNNotificationRequest *request = [UNNotificationRequest requestWithIdentifier:nsIdentifier content:content trigger:trigger];
+
+ [self sendNotificationWithRequest:request channelID:channelID];
+}
+
+- (void) RegisterNotificationCategory:(int)channelID :(const char *)categoryId :(const char *)actionsJSON :(bool)hasReplyField :(const char *)replyPlaceholder :(const char *)replyButtonTitle API_AVAILABLE(macos(10.14)) {
+ if (![self EnsureDelegateInitialized]) {
+ NSString *errorMsg = @"Notification delegate has been lost. Reinitialize the notification service.";
+ captureResult(channelID, false, [errorMsg UTF8String]);
+ return;
+ }
+
+ NSString *nsCategoryId = [NSString stringWithUTF8String:categoryId];
+ NSString *actionsJsonStr = actionsJSON ? [NSString stringWithUTF8String:actionsJSON] : @"[]";
+
+ NSData *jsonData = [actionsJsonStr dataUsingEncoding:NSUTF8StringEncoding];
+ NSError *error = nil;
+ NSArray *actionsArray = [NSJSONSerialization JSONObjectWithData:jsonData options:0 error:&error];
+
+ if (error) {
+ NSString *errorMsg = [NSString stringWithFormat:@"Error: %@", [error localizedDescription]];
+ captureResult(channelID, false, [errorMsg UTF8String]);
+ return;
+ }
+
+ NSMutableArray *actions = [NSMutableArray array];
+ for (NSDictionary *actionDict in actionsArray) {
+ NSString *actionId = actionDict[@"id"];
+ NSString *actionTitle = actionDict[@"title"];
+ BOOL destructive = [actionDict[@"destructive"] boolValue];
+
+ if (actionId && actionTitle) {
+ UNNotificationActionOptions options = UNNotificationActionOptionNone;
+ if (destructive) options |= UNNotificationActionOptionDestructive;
+
+ UNNotificationAction *action = [UNNotificationAction actionWithIdentifier:actionId
+ title:actionTitle
+ options:options];
+ [actions addObject:action];
+ }
+ }
+
+ if (hasReplyField) {
+ // Defensive NULL checks: if hasReplyField is true, both strings must be non-NULL
+ if (!replyPlaceholder || !replyButtonTitle) {
+ NSString *errorMsg = @"hasReplyField is true but replyPlaceholder or replyButtonTitle is NULL";
+ captureResult(channelID, false, [errorMsg UTF8String]);
+ return;
+ }
+ NSString *placeholder = [NSString stringWithUTF8String:replyPlaceholder];
+ NSString *buttonTitle = [NSString stringWithUTF8String:replyButtonTitle];
+ UNTextInputNotificationAction *textAction =
+ [UNTextInputNotificationAction actionWithIdentifier:@"TEXT_REPLY"
+ title:buttonTitle
+ options:UNNotificationActionOptionNone
+ textInputButtonTitle:buttonTitle
+ textInputPlaceholder:placeholder];
+ [actions addObject:textAction];
+ }
+
+ UNNotificationCategory *newCategory = [UNNotificationCategory categoryWithIdentifier:nsCategoryId
+ actions:actions
+ intentIdentifiers:@[]
+ options:UNNotificationCategoryOptionNone];
+
+ UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
+ [center getNotificationCategoriesWithCompletionHandler:^(NSSet *categories) {
+ NSMutableSet *updatedCategories = [NSMutableSet setWithSet:categories];
+
+ // Remove existing category with same identifier if found
+ UNNotificationCategory *existingCategory = nil;
+ for (UNNotificationCategory *category in updatedCategories) {
+ if ([category.identifier isEqualToString:nsCategoryId]) {
+ existingCategory = category;
+ break;
+ }
+ }
+ if (existingCategory) {
+ [updatedCategories removeObject:existingCategory];
+ }
+
+ // Add the new category
+ [updatedCategories addObject:newCategory];
+ [center setNotificationCategories:updatedCategories];
+
+ captureResult(channelID, true, NULL);
+ }];
+}
+
+- (void) RemoveNotificationCategory:(int)channelID :(const char *)categoryId API_AVAILABLE(macos(10.14)) {
+ NSString *nsCategoryId = [NSString stringWithUTF8String:categoryId];
+ UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
+
+ [center getNotificationCategoriesWithCompletionHandler:^(NSSet *categories) {
+ NSMutableSet *updatedCategories = [NSMutableSet setWithSet:categories];
+
+ // Find and remove the matching category
+ UNNotificationCategory *categoryToRemove = nil;
+ for (UNNotificationCategory *category in updatedCategories) {
+ if ([category.identifier isEqualToString:nsCategoryId]) {
+ categoryToRemove = category;
+ break;
+ }
+ }
+
+ if (categoryToRemove) {
+ [updatedCategories removeObject:categoryToRemove];
+ [center setNotificationCategories:updatedCategories];
+ captureResult(channelID, true, NULL);
+ } else {
+ NSString *errorMsg = [NSString stringWithFormat:@"Category '%@' not found", nsCategoryId];
+ captureResult(channelID, false, [errorMsg UTF8String]);
+ }
+ }];
+}
+
+- (void) RemoveAllPendingNotifications API_AVAILABLE(macos(10.14)) {
+ UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
+ [center removeAllPendingNotificationRequests];
+}
+
+- (void) RemovePendingNotification:(const char *)identifier API_AVAILABLE(macos(10.14)) {
+ NSString *nsIdentifier = [NSString stringWithUTF8String:identifier];
+ UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
+ [center removePendingNotificationRequestsWithIdentifiers:@[nsIdentifier]];
+}
+
+- (void) RemoveAllDeliveredNotifications API_AVAILABLE(macos(10.14)) {
+ UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
+ [center removeAllDeliveredNotifications];
+}
+
+- (void) RemoveDeliveredNotification:(const char *)identifier API_AVAILABLE(macos(10.14)) {
+ NSString *nsIdentifier = [NSString stringWithUTF8String:identifier];
+ UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
+ [center removeDeliveredNotificationsWithIdentifiers:@[nsIdentifier]];
+}
+
+
- (void) SetAbout :(NSString*)title :(NSString*)description :(void*)imagedata :(int)datalen {
self.aboutTitle = title;
self.aboutDescription = description;
@@ -731,7 +1091,7 @@ typedef void (^schemeTaskCaller)(id);
self.aboutImage = [[NSImage alloc] initWithData:imageData];
}
--(void) About {
+- (void) About {
WailsAlert *alert = [WailsAlert new];
[alert setAlertStyle:NSAlertStyleInformational];
diff --git a/v2/internal/frontend/desktop/darwin/notifications.go b/v2/internal/frontend/desktop/darwin/notifications.go
new file mode 100644
index 000000000..b788841e0
--- /dev/null
+++ b/v2/internal/frontend/desktop/darwin/notifications.go
@@ -0,0 +1,465 @@
+//go:build darwin
+// +build darwin
+
+package darwin
+
+/*
+#cgo CFLAGS:-x objective-c
+#cgo LDFLAGS: -framework Foundation -framework Cocoa
+
+#if __MAC_OS_X_VERSION_MAX_ALLOWED >= 110000
+#cgo LDFLAGS: -framework UserNotifications
+#endif
+
+#import "Application.h"
+#import "WailsContext.h"
+*/
+import "C"
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "sync"
+ "time"
+ "unsafe"
+
+ "github.com/wailsapp/wails/v2/internal/frontend"
+)
+
+// Package-scoped variable only accessible within this file
+var (
+ currentFrontend *Frontend
+ frontendMutex sync.RWMutex
+ // Notification channels
+ channels map[int]chan notificationChannel
+ channelsLock sync.Mutex
+ nextChannelID int
+
+ notificationResultCallback func(result frontend.NotificationResult)
+ callbackLock sync.RWMutex
+)
+
+const DefaultActionIdentifier = "DEFAULT_ACTION"
+const AppleDefaultActionIdentifier = "com.apple.UNNotificationDefaultActionIdentifier"
+
+// setCurrentFrontend sets the current frontend instance
+// This is called when RequestNotificationAuthorization or CheckNotificationAuthorization is called
+func setCurrentFrontend(f *Frontend) {
+ frontendMutex.Lock()
+ defer frontendMutex.Unlock()
+ currentFrontend = f
+}
+
+// getCurrentFrontend gets the current frontend instance
+func getCurrentFrontend() *Frontend {
+ frontendMutex.RLock()
+ defer frontendMutex.RUnlock()
+ return currentFrontend
+}
+
+type notificationChannel struct {
+ Success bool
+ Error error
+}
+
+func (f *Frontend) InitializeNotifications() error {
+ if !f.IsNotificationAvailable() {
+ return fmt.Errorf("notifications are not available on this system")
+ }
+ if !f.checkBundleIdentifier() {
+ return fmt.Errorf("notifications require a valid bundle identifier")
+ }
+ if !bool(C.EnsureDelegateInitialized(f.mainWindow.context)) {
+ return fmt.Errorf("failed to initialize notification center delegate")
+ }
+
+ channels = make(map[int]chan notificationChannel)
+ nextChannelID = 0
+
+ setCurrentFrontend(f)
+
+ return nil
+}
+
+// CleanupNotifications is a macOS stub that does nothing.
+// (Linux-specific cleanup)
+func (f *Frontend) CleanupNotifications() {
+ // No cleanup needed on macOS
+}
+
+func (f *Frontend) IsNotificationAvailable() bool {
+ return bool(C.IsNotificationAvailable(f.mainWindow.context))
+}
+
+func (f *Frontend) checkBundleIdentifier() bool {
+ return bool(C.CheckBundleIdentifier(f.mainWindow.context))
+}
+
+func (f *Frontend) RequestNotificationAuthorization() (bool, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
+ defer cancel()
+
+ id, resultCh := f.registerChannel()
+
+ C.RequestNotificationAuthorization(f.mainWindow.context, C.int(id))
+
+ select {
+ case result := <-resultCh:
+ close(resultCh)
+ return result.Success, result.Error
+ case <-ctx.Done():
+ f.cleanupChannel(id)
+ return false, fmt.Errorf("notification authorization timed out after 3 minutes: %w", ctx.Err())
+ }
+}
+
+func (f *Frontend) CheckNotificationAuthorization() (bool, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+
+ id, resultCh := f.registerChannel()
+
+ C.CheckNotificationAuthorization(f.mainWindow.context, C.int(id))
+
+ select {
+ case result := <-resultCh:
+ close(resultCh)
+ return result.Success, result.Error
+ case <-ctx.Done():
+ f.cleanupChannel(id)
+ return false, fmt.Errorf("notification authorization timed out after 15s: %w", ctx.Err())
+ }
+}
+
+// SendNotification sends a basic notification with a unique identifier, title, subtitle, and body.
+func (f *Frontend) SendNotification(options frontend.NotificationOptions) error {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ cIdentifier := C.CString(options.ID)
+ cTitle := C.CString(options.Title)
+ cSubtitle := C.CString(options.Subtitle)
+ cBody := C.CString(options.Body)
+ defer C.free(unsafe.Pointer(cIdentifier))
+ defer C.free(unsafe.Pointer(cTitle))
+ defer C.free(unsafe.Pointer(cSubtitle))
+ defer C.free(unsafe.Pointer(cBody))
+
+ var cDataJSON *C.char
+ if options.Data != nil {
+ jsonData, err := json.Marshal(options.Data)
+ if err != nil {
+ return fmt.Errorf("failed to marshal notification data: %w", err)
+ }
+ cDataJSON = C.CString(string(jsonData))
+ defer C.free(unsafe.Pointer(cDataJSON))
+ }
+
+ id, resultCh := f.registerChannel()
+ C.SendNotification(f.mainWindow.context, C.int(id), cIdentifier, cTitle, cSubtitle, cBody, cDataJSON)
+
+ select {
+ case result := <-resultCh:
+ close(resultCh)
+ if !result.Success {
+ if result.Error != nil {
+ return result.Error
+ }
+ return fmt.Errorf("sending notification failed")
+ }
+ return nil
+ case <-ctx.Done():
+ f.cleanupChannel(id)
+ return fmt.Errorf("sending notification timed out: %w", ctx.Err())
+ }
+}
+
+// SendNotificationWithActions sends a notification with additional actions and inputs.
+// A NotificationCategory must be registered with RegisterNotificationCategory first. The `CategoryID` must match the registered category.
+// If a NotificationCategory is not registered a basic notification will be sent.
+func (f *Frontend) SendNotificationWithActions(options frontend.NotificationOptions) error {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ cIdentifier := C.CString(options.ID)
+ cTitle := C.CString(options.Title)
+ cSubtitle := C.CString(options.Subtitle)
+ cBody := C.CString(options.Body)
+ cCategoryID := C.CString(options.CategoryID)
+ defer C.free(unsafe.Pointer(cIdentifier))
+ defer C.free(unsafe.Pointer(cTitle))
+ defer C.free(unsafe.Pointer(cSubtitle))
+ defer C.free(unsafe.Pointer(cBody))
+ defer C.free(unsafe.Pointer(cCategoryID))
+
+ var cDataJSON *C.char
+ if options.Data != nil {
+ jsonData, err := json.Marshal(options.Data)
+ if err != nil {
+ return fmt.Errorf("failed to marshal notification data: %w", err)
+ }
+ cDataJSON = C.CString(string(jsonData))
+ defer C.free(unsafe.Pointer(cDataJSON))
+ }
+
+ id, resultCh := f.registerChannel()
+ C.SendNotificationWithActions(f.mainWindow.context, C.int(id), cIdentifier, cTitle, cSubtitle, cBody, cCategoryID, cDataJSON)
+
+ select {
+ case result := <-resultCh:
+ close(resultCh)
+ if !result.Success {
+ if result.Error != nil {
+ return result.Error
+ }
+ return fmt.Errorf("sending notification failed")
+ }
+ return nil
+ case <-ctx.Done():
+ f.cleanupChannel(id)
+ return fmt.Errorf("sending notification timed out: %w", ctx.Err())
+ }
+}
+
+// RegisterNotificationCategory registers a new NotificationCategory to be used with SendNotificationWithActions.
+// Registering a category with the same name as a previously registered NotificationCategory will override it.
+func (f *Frontend) RegisterNotificationCategory(category frontend.NotificationCategory) error {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ cCategoryID := C.CString(category.ID)
+ defer C.free(unsafe.Pointer(cCategoryID))
+
+ actionsJSON, err := json.Marshal(category.Actions)
+ if err != nil {
+ return fmt.Errorf("failed to marshal notification category: %w", err)
+ }
+ cActionsJSON := C.CString(string(actionsJSON))
+ defer C.free(unsafe.Pointer(cActionsJSON))
+
+ var cReplyPlaceholder, cReplyButtonTitle *C.char
+ if category.HasReplyField {
+ cReplyPlaceholder = C.CString(category.ReplyPlaceholder)
+ cReplyButtonTitle = C.CString(category.ReplyButtonTitle)
+ defer C.free(unsafe.Pointer(cReplyPlaceholder))
+ defer C.free(unsafe.Pointer(cReplyButtonTitle))
+ }
+
+ id, resultCh := f.registerChannel()
+ C.RegisterNotificationCategory(f.mainWindow.context, C.int(id), cCategoryID, cActionsJSON, C.bool(category.HasReplyField),
+ cReplyPlaceholder, cReplyButtonTitle)
+
+ select {
+ case result := <-resultCh:
+ close(resultCh)
+ if !result.Success {
+ if result.Error != nil {
+ return result.Error
+ }
+ return fmt.Errorf("category registration failed")
+ }
+ return nil
+ case <-ctx.Done():
+ f.cleanupChannel(id)
+ return fmt.Errorf("category registration timed out: %w", ctx.Err())
+ }
+}
+
+// RemoveNotificationCategory remove a previously registered NotificationCategory.
+func (f *Frontend) RemoveNotificationCategory(categoryId string) error {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ cCategoryID := C.CString(categoryId)
+ defer C.free(unsafe.Pointer(cCategoryID))
+
+ id, resultCh := f.registerChannel()
+ C.RemoveNotificationCategory(f.mainWindow.context, C.int(id), cCategoryID)
+
+ select {
+ case result := <-resultCh:
+ close(resultCh)
+ if !result.Success {
+ if result.Error != nil {
+ return result.Error
+ }
+ return fmt.Errorf("category removal failed")
+ }
+ return nil
+ case <-ctx.Done():
+ f.cleanupChannel(id)
+ return fmt.Errorf("category removal timed out: %w", ctx.Err())
+ }
+}
+
+// RemoveAllPendingNotifications removes all pending notifications.
+func (f *Frontend) RemoveAllPendingNotifications() error {
+ C.RemoveAllPendingNotifications(f.mainWindow.context)
+ return nil
+}
+
+// RemovePendingNotification removes a pending notification matching the unique identifier.
+func (f *Frontend) RemovePendingNotification(identifier string) error {
+ cIdentifier := C.CString(identifier)
+ defer C.free(unsafe.Pointer(cIdentifier))
+ C.RemovePendingNotification(f.mainWindow.context, cIdentifier)
+ return nil
+}
+
+// RemoveAllDeliveredNotifications removes all delivered notifications.
+func (f *Frontend) RemoveAllDeliveredNotifications() error {
+ C.RemoveAllDeliveredNotifications(f.mainWindow.context)
+ return nil
+}
+
+// RemoveDeliveredNotification removes a delivered notification matching the unique identifier.
+func (f *Frontend) RemoveDeliveredNotification(identifier string) error {
+ cIdentifier := C.CString(identifier)
+ defer C.free(unsafe.Pointer(cIdentifier))
+ C.RemoveDeliveredNotification(f.mainWindow.context, cIdentifier)
+ return nil
+}
+
+// RemoveNotification is a macOS stub that always returns nil.
+// Use one of the following instead:
+// RemoveAllPendingNotifications
+// RemovePendingNotification
+// RemoveAllDeliveredNotifications
+// RemoveDeliveredNotification
+// (Linux-specific)
+func (f *Frontend) RemoveNotification(identifier string) error {
+ return nil
+}
+
+func (f *Frontend) OnNotificationResponse(callback func(result frontend.NotificationResult)) {
+ callbackLock.Lock()
+ notificationResultCallback = callback
+ callbackLock.Unlock()
+}
+
+//export captureResult
+func captureResult(channelID C.int, success C.bool, errorMsg *C.char) {
+ f := getCurrentFrontend()
+ if f == nil {
+ return
+ }
+
+ resultCh, exists := f.GetChannel(int(channelID))
+ if !exists {
+ return
+ }
+
+ var err error
+ if errorMsg != nil {
+ err = fmt.Errorf("%s", C.GoString(errorMsg))
+ }
+
+ resultCh <- notificationChannel{
+ Success: bool(success),
+ Error: err,
+ }
+}
+
+//export didReceiveNotificationResponse
+func didReceiveNotificationResponse(jsonPayload *C.char, err *C.char) {
+ result := frontend.NotificationResult{}
+
+ if err != nil {
+ errMsg := C.GoString(err)
+ result.Error = fmt.Errorf("notification response error: %s", errMsg)
+ handleNotificationResult(result)
+
+ return
+ }
+
+ if jsonPayload == nil {
+ result.Error = fmt.Errorf("received nil JSON payload in notification response")
+ handleNotificationResult(result)
+ return
+ }
+
+ payload := C.GoString(jsonPayload)
+
+ var response frontend.NotificationResponse
+ if err := json.Unmarshal([]byte(payload), &response); err != nil {
+ result.Error = fmt.Errorf("failed to unmarshal notification response: %w", err)
+ handleNotificationResult(result)
+ return
+ }
+
+ if response.ActionIdentifier == AppleDefaultActionIdentifier {
+ response.ActionIdentifier = DefaultActionIdentifier
+ }
+
+ result.Response = response
+ handleNotificationResult(result)
+}
+
+func handleNotificationResult(result frontend.NotificationResult) {
+ callbackLock.Lock()
+ callback := notificationResultCallback
+ callbackLock.Unlock()
+
+ if callback != nil {
+ go func() {
+ defer func() {
+ if r := recover(); r != nil {
+ // Log panic but don't crash the app
+ fmt.Fprintf(os.Stderr, "panic in notification callback: %v\n", r)
+ }
+ }()
+ callback(result)
+ }()
+ }
+}
+
+// Helper methods
+
+func (f *Frontend) registerChannel() (int, chan notificationChannel) {
+ channelsLock.Lock()
+ defer channelsLock.Unlock()
+
+ // Initialize channels map if it's nil
+ if channels == nil {
+ channels = make(map[int]chan notificationChannel)
+ nextChannelID = 0
+ }
+
+ id := nextChannelID
+ nextChannelID++
+
+ resultCh := make(chan notificationChannel, 1)
+
+ channels[id] = resultCh
+ return id, resultCh
+}
+
+func (f *Frontend) GetChannel(id int) (chan notificationChannel, bool) {
+ channelsLock.Lock()
+ defer channelsLock.Unlock()
+
+ if channels == nil {
+ return nil, false
+ }
+
+ ch, exists := channels[id]
+ if exists {
+ delete(channels, id)
+ }
+ return ch, exists
+}
+
+func (f *Frontend) cleanupChannel(id int) {
+ channelsLock.Lock()
+ defer channelsLock.Unlock()
+
+ if channels == nil {
+ return
+ }
+
+ if ch, exists := channels[id]; exists {
+ delete(channels, id)
+ close(ch)
+ }
+}
diff --git a/v2/internal/frontend/desktop/linux/notifications.go b/v2/internal/frontend/desktop/linux/notifications.go
new file mode 100644
index 000000000..80f0ae569
--- /dev/null
+++ b/v2/internal/frontend/desktop/linux/notifications.go
@@ -0,0 +1,594 @@
+//go:build linux
+// +build linux
+
+package linux
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync"
+
+ "github.com/godbus/dbus/v5"
+ "github.com/wailsapp/wails/v2/internal/frontend"
+)
+
+var (
+ conn *dbus.Conn
+ categories map[string]frontend.NotificationCategory = make(map[string]frontend.NotificationCategory)
+ categoriesLock sync.RWMutex
+ notifications map[uint32]*notificationData = make(map[uint32]*notificationData)
+ notificationsLock sync.RWMutex
+ notificationResultCallback func(result frontend.NotificationResult)
+ callbackLock sync.RWMutex
+ appName string
+ cancel context.CancelFunc
+)
+
+type notificationData struct {
+ ID string
+ Title string
+ Subtitle string
+ Body string
+ CategoryID string
+ Data map[string]interface{}
+ DBusID uint32
+ ActionMap map[string]string
+}
+
+const (
+ dbusNotificationInterface = "org.freedesktop.Notifications"
+ dbusNotificationPath = "/org/freedesktop/Notifications"
+ DefaultActionIdentifier = "DEFAULT_ACTION"
+)
+
+// Creates a new Notifications Service.
+func (f *Frontend) InitializeNotifications() error {
+ // Clean up any previous initialization
+ f.CleanupNotifications()
+
+ exe, err := os.Executable()
+ if err != nil {
+ return fmt.Errorf("failed to get executable: %w", err)
+ }
+ appName = filepath.Base(exe)
+
+ _conn, err := dbus.ConnectSessionBus()
+ if err != nil {
+ return fmt.Errorf("failed to connect to session bus: %w", err)
+ }
+ conn = _conn
+
+ if err := f.loadCategories(); err != nil {
+ f.logger.Warning("Failed to load notification categories: %v", err)
+ }
+
+ var signalCtx context.Context
+ signalCtx, cancel = context.WithCancel(context.Background())
+
+ if err := f.setupSignalHandling(signalCtx); err != nil {
+ return fmt.Errorf("failed to set up notification signal handling: %w", err)
+ }
+
+ return nil
+}
+
+// CleanupNotifications cleans up notification resources
+func (f *Frontend) CleanupNotifications() {
+ if cancel != nil {
+ cancel()
+ cancel = nil
+ }
+
+ if conn != nil {
+ conn.Close()
+ conn = nil
+ }
+}
+
+func (f *Frontend) IsNotificationAvailable() bool {
+ return true
+}
+
+// RequestNotificationAuthorization is a Linux stub that always returns true, nil.
+// (authorization is macOS-specific)
+func (f *Frontend) RequestNotificationAuthorization() (bool, error) {
+ return true, nil
+}
+
+// CheckNotificationAuthorization is a Linux stub that always returns true.
+// (authorization is macOS-specific)
+func (f *Frontend) CheckNotificationAuthorization() (bool, error) {
+ return true, nil
+}
+
+// SendNotification sends a basic notification with a unique identifier, title, subtitle, and body.
+func (f *Frontend) SendNotification(options frontend.NotificationOptions) error {
+ if conn == nil {
+ return fmt.Errorf("notifications not initialized")
+ }
+
+ hints := map[string]dbus.Variant{}
+
+ body := options.Body
+ if options.Subtitle != "" {
+ body = options.Subtitle + "\n" + body
+ }
+
+ defaultActionID := "default"
+ actions := []string{defaultActionID, "Default"}
+
+ actionMap := map[string]string{
+ defaultActionID: DefaultActionIdentifier,
+ }
+
+ hints["x-notification-id"] = dbus.MakeVariant(options.ID)
+
+ if options.Data != nil {
+ userData, err := json.Marshal(options.Data)
+ if err == nil {
+ hints["x-user-data"] = dbus.MakeVariant(string(userData))
+ }
+ }
+
+ // Call the Notify method on the D-Bus interface
+ obj := conn.Object(dbusNotificationInterface, dbusNotificationPath)
+ call := obj.Call(
+ dbusNotificationInterface+".Notify",
+ 0,
+ appName,
+ uint32(0),
+ "", // Icon
+ options.Title,
+ body,
+ actions,
+ hints,
+ int32(-1),
+ )
+
+ if call.Err != nil {
+ return fmt.Errorf("failed to send notification: %w", call.Err)
+ }
+
+ var dbusID uint32
+ if err := call.Store(&dbusID); err != nil {
+ return fmt.Errorf("failed to store notification ID: %w", err)
+ }
+
+ notification := ¬ificationData{
+ ID: options.ID,
+ Title: options.Title,
+ Subtitle: options.Subtitle,
+ Body: options.Body,
+ Data: options.Data,
+ DBusID: dbusID,
+ ActionMap: actionMap,
+ }
+
+ notificationsLock.Lock()
+ notifications[dbusID] = notification
+ notificationsLock.Unlock()
+
+ return nil
+}
+
+// SendNotificationWithActions sends a notification with additional actions.
+func (f *Frontend) SendNotificationWithActions(options frontend.NotificationOptions) error {
+ if conn == nil {
+ return fmt.Errorf("notifications not initialized")
+ }
+
+ categoriesLock.RLock()
+ category, exists := categories[options.CategoryID]
+ categoriesLock.RUnlock()
+
+ if options.CategoryID == "" || !exists {
+ // Fall back to basic notification
+ return f.SendNotification(options)
+ }
+
+ body := options.Body
+ if options.Subtitle != "" {
+ body = options.Subtitle + "\n" + body
+ }
+
+ var actions []string
+ actionMap := make(map[string]string)
+
+ defaultActionID := "default"
+ actions = append(actions, defaultActionID, "Default")
+ actionMap[defaultActionID] = DefaultActionIdentifier
+
+ for _, action := range category.Actions {
+ actions = append(actions, action.ID, action.Title)
+ actionMap[action.ID] = action.ID
+ }
+
+ hints := map[string]dbus.Variant{}
+
+ hints["x-notification-id"] = dbus.MakeVariant(options.ID)
+
+ hints["x-category-id"] = dbus.MakeVariant(options.CategoryID)
+
+ if options.Data != nil {
+ userData, err := json.Marshal(options.Data)
+ if err == nil {
+ hints["x-user-data"] = dbus.MakeVariant(string(userData))
+ }
+ }
+
+ obj := conn.Object(dbusNotificationInterface, dbusNotificationPath)
+ call := obj.Call(
+ dbusNotificationInterface+".Notify",
+ 0,
+ appName,
+ uint32(0),
+ "", // Icon
+ options.Title,
+ body,
+ actions,
+ hints,
+ int32(-1),
+ )
+
+ if call.Err != nil {
+ return fmt.Errorf("failed to send notification: %w", call.Err)
+ }
+
+ var dbusID uint32
+ if err := call.Store(&dbusID); err != nil {
+ return fmt.Errorf("failed to store notification ID: %w", err)
+ }
+
+ notification := ¬ificationData{
+ ID: options.ID,
+ Title: options.Title,
+ Subtitle: options.Subtitle,
+ Body: options.Body,
+ CategoryID: options.CategoryID,
+ Data: options.Data,
+ DBusID: dbusID,
+ ActionMap: actionMap,
+ }
+
+ notificationsLock.Lock()
+ notifications[dbusID] = notification
+ notificationsLock.Unlock()
+
+ return nil
+}
+
+// RegisterNotificationCategory registers a new NotificationCategory to be used with SendNotificationWithActions.
+func (f *Frontend) RegisterNotificationCategory(category frontend.NotificationCategory) error {
+ categoriesLock.Lock()
+ categories[category.ID] = category
+ categoriesLock.Unlock()
+
+ if err := f.saveCategories(); err != nil {
+ f.logger.Warning("Failed to save notification categories: %v", err)
+ }
+
+ return nil
+}
+
+// RemoveNotificationCategory removes a previously registered NotificationCategory.
+func (f *Frontend) RemoveNotificationCategory(categoryId string) error {
+ categoriesLock.Lock()
+ delete(categories, categoryId)
+ categoriesLock.Unlock()
+
+ if err := f.saveCategories(); err != nil {
+ f.logger.Warning("Failed to save notification categories: %v", err)
+ }
+
+ return nil
+}
+
+// RemoveAllPendingNotifications attempts to remove all active notifications.
+func (f *Frontend) RemoveAllPendingNotifications() error {
+ notificationsLock.Lock()
+ dbusIDs := make([]uint32, 0, len(notifications))
+ for id := range notifications {
+ dbusIDs = append(dbusIDs, id)
+ }
+ notificationsLock.Unlock()
+
+ for _, id := range dbusIDs {
+ f.closeNotification(id)
+ }
+
+ return nil
+}
+
+// RemovePendingNotification removes a pending notification.
+func (f *Frontend) RemovePendingNotification(identifier string) error {
+ var dbusID uint32
+ found := false
+
+ notificationsLock.Lock()
+ for id, notif := range notifications {
+ if notif.ID == identifier {
+ dbusID = id
+ found = true
+ break
+ }
+ }
+ notificationsLock.Unlock()
+
+ if !found {
+ return nil
+ }
+
+ return f.closeNotification(dbusID)
+}
+
+// RemoveAllDeliveredNotifications functionally equivalent to RemoveAllPendingNotification on Linux.
+func (f *Frontend) RemoveAllDeliveredNotifications() error {
+ return f.RemoveAllPendingNotifications()
+}
+
+// RemoveDeliveredNotification functionally equivalent RemovePendingNotification on Linux.
+func (f *Frontend) RemoveDeliveredNotification(identifier string) error {
+ return f.RemovePendingNotification(identifier)
+}
+
+// RemoveNotification removes a notification by identifier.
+func (f *Frontend) RemoveNotification(identifier string) error {
+ return f.RemovePendingNotification(identifier)
+}
+
+func (f *Frontend) OnNotificationResponse(callback func(result frontend.NotificationResult)) {
+ callbackLock.Lock()
+ defer callbackLock.Unlock()
+
+ notificationResultCallback = callback
+}
+
+// Helper method to close a notification.
+func (f *Frontend) closeNotification(id uint32) error {
+ if conn == nil {
+ return fmt.Errorf("notifications not initialized")
+ }
+
+ obj := conn.Object(dbusNotificationInterface, dbusNotificationPath)
+ call := obj.Call(dbusNotificationInterface+".CloseNotification", 0, id)
+
+ if call.Err != nil {
+ return fmt.Errorf("failed to close notification: %w", call.Err)
+ }
+
+ return nil
+}
+
+func (f *Frontend) getConfigDir() (string, error) {
+ configDir, err := os.UserConfigDir()
+ if err != nil {
+ return "", fmt.Errorf("failed to get user config directory: %w", err)
+ }
+
+ appConfigDir := filepath.Join(configDir, appName)
+ if err := os.MkdirAll(appConfigDir, 0755); err != nil {
+ return "", fmt.Errorf("failed to create app config directory: %w", err)
+ }
+
+ return appConfigDir, nil
+}
+
+// Save notification categories.
+func (f *Frontend) saveCategories() error {
+ configDir, err := f.getConfigDir()
+ if err != nil {
+ return err
+ }
+
+ categoriesFile := filepath.Join(configDir, "notification-categories.json")
+
+ categoriesLock.RLock()
+ categoriesData, err := json.MarshalIndent(categories, "", " ")
+ categoriesLock.RUnlock()
+
+ if err != nil {
+ return fmt.Errorf("failed to marshal notification categories: %w", err)
+ }
+
+ if err := os.WriteFile(categoriesFile, categoriesData, 0644); err != nil {
+ return fmt.Errorf("failed to write notification categories to disk: %w", err)
+ }
+
+ return nil
+}
+
+// Load notification categories.
+func (f *Frontend) loadCategories() error {
+ configDir, err := f.getConfigDir()
+ if err != nil {
+ return err
+ }
+
+ categoriesFile := filepath.Join(configDir, "notification-categories.json")
+
+ if _, err := os.Stat(categoriesFile); os.IsNotExist(err) {
+ return nil
+ }
+
+ categoriesData, err := os.ReadFile(categoriesFile)
+ if err != nil {
+ return fmt.Errorf("failed to read notification categories from disk: %w", err)
+ }
+
+ _categories := make(map[string]frontend.NotificationCategory)
+ if err := json.Unmarshal(categoriesData, &_categories); err != nil {
+ return fmt.Errorf("failed to unmarshal notification categories: %w", err)
+ }
+
+ categoriesLock.Lock()
+ categories = _categories
+ categoriesLock.Unlock()
+
+ return nil
+}
+
+// Setup signal handling for notification actions.
+func (f *Frontend) setupSignalHandling(ctx context.Context) error {
+ if err := conn.AddMatchSignal(
+ dbus.WithMatchInterface(dbusNotificationInterface),
+ dbus.WithMatchMember("ActionInvoked"),
+ ); err != nil {
+ return err
+ }
+
+ if err := conn.AddMatchSignal(
+ dbus.WithMatchInterface(dbusNotificationInterface),
+ dbus.WithMatchMember("NotificationClosed"),
+ ); err != nil {
+ return err
+ }
+
+ c := make(chan *dbus.Signal, 10)
+ conn.Signal(c)
+
+ go f.handleSignals(ctx, c)
+
+ return nil
+}
+
+// Handle incoming D-Bus signals.
+func (f *Frontend) handleSignals(ctx context.Context, c chan *dbus.Signal) {
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case signal, ok := <-c:
+ if !ok {
+ return
+ }
+
+ switch signal.Name {
+ case dbusNotificationInterface + ".ActionInvoked":
+ f.handleActionInvoked(signal)
+ case dbusNotificationInterface + ".NotificationClosed":
+ f.handleNotificationClosed(signal)
+ }
+ }
+ }
+}
+
+// Handle ActionInvoked signal.
+func (f *Frontend) handleActionInvoked(signal *dbus.Signal) {
+ if len(signal.Body) < 2 {
+ return
+ }
+
+ dbusID, ok := signal.Body[0].(uint32)
+ if !ok {
+ return
+ }
+
+ actionID, ok := signal.Body[1].(string)
+ if !ok {
+ return
+ }
+
+ notificationsLock.Lock()
+ notification, exists := notifications[dbusID]
+ if exists {
+ delete(notifications, dbusID)
+ }
+ notificationsLock.Unlock()
+
+ if !exists {
+ return
+ }
+
+ appActionID, ok := notification.ActionMap[actionID]
+ if !ok {
+ appActionID = actionID
+ }
+
+ response := frontend.NotificationResponse{
+ ID: notification.ID,
+ ActionIdentifier: appActionID,
+ Title: notification.Title,
+ Subtitle: notification.Subtitle,
+ Body: notification.Body,
+ CategoryID: notification.CategoryID,
+ UserInfo: notification.Data,
+ }
+
+ result := frontend.NotificationResult{
+ Response: response,
+ }
+
+ handleNotificationResult(result)
+}
+
+func handleNotificationResult(result frontend.NotificationResult) {
+ callbackLock.Lock()
+ callback := notificationResultCallback
+ callbackLock.Unlock()
+
+ if callback != nil {
+ go func() {
+ defer func() {
+ if r := recover(); r != nil {
+ // Log panic but don't crash the app
+ fmt.Fprintf(os.Stderr, "panic in notification callback: %v\n", r)
+ }
+ }()
+ callback(result)
+ }()
+ }
+}
+
+// Handle NotificationClosed signal.
+// Reason codes:
+// 1 - expired timeout
+// 2 - dismissed by user (click on X)
+// 3 - closed by CloseNotification call
+// 4 - undefined/reserved
+func (f *Frontend) handleNotificationClosed(signal *dbus.Signal) {
+ if len(signal.Body) < 2 {
+ return
+ }
+
+ dbusID, ok := signal.Body[0].(uint32)
+ if !ok {
+ return
+ }
+
+ reason, ok := signal.Body[1].(uint32)
+ if !ok {
+ reason = 0 // Unknown reason
+ }
+
+ notificationsLock.Lock()
+ notification, exists := notifications[dbusID]
+ if exists {
+ delete(notifications, dbusID)
+ }
+ notificationsLock.Unlock()
+
+ if !exists {
+ return
+ }
+
+ if reason == 2 {
+ response := frontend.NotificationResponse{
+ ID: notification.ID,
+ ActionIdentifier: DefaultActionIdentifier,
+ Title: notification.Title,
+ Subtitle: notification.Subtitle,
+ Body: notification.Body,
+ CategoryID: notification.CategoryID,
+ UserInfo: notification.Data,
+ }
+
+ result := frontend.NotificationResult{
+ Response: response,
+ }
+
+ handleNotificationResult(result)
+ }
+}
diff --git a/v2/internal/frontend/desktop/windows/notifications.go b/v2/internal/frontend/desktop/windows/notifications.go
new file mode 100644
index 000000000..0176b7077
--- /dev/null
+++ b/v2/internal/frontend/desktop/windows/notifications.go
@@ -0,0 +1,489 @@
+//go:build windows
+// +build windows
+
+package windows
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "log"
+ "sync"
+
+ wintoast "git.sr.ht/~jackmordaunt/go-toast/v2/wintoast"
+ "github.com/google/uuid"
+ "github.com/wailsapp/wails/v2/internal/frontend"
+ "github.com/wailsapp/wails/v2/internal/frontend/desktop/windows/winc"
+ "github.com/wailsapp/wails/v2/internal/frontend/desktop/windows/winc/w32"
+
+ "fmt"
+ "os"
+ "path/filepath"
+ _ "unsafe" // for go:linkname
+
+ "git.sr.ht/~jackmordaunt/go-toast/v2"
+ "golang.org/x/sys/windows/registry"
+)
+
+var (
+ categories map[string]frontend.NotificationCategory
+ categoriesLock sync.RWMutex
+ appName string
+ appGUID string
+ iconPath string = ""
+ exePath string
+ iconOnce sync.Once
+ iconErr error
+
+ notificationResultCallback func(result frontend.NotificationResult)
+ callbackLock sync.RWMutex
+)
+
+const DefaultActionIdentifier = "DEFAULT_ACTION"
+
+const (
+ ToastRegistryPath = `Software\Classes\AppUserModelId\`
+ ToastRegistryGuidKey = "CustomActivator"
+ NotificationCategoriesRegistryPath = `SOFTWARE\%s\NotificationCategories`
+ NotificationCategoriesRegistryKey = "Categories"
+)
+
+// NotificationPayload combines the action ID and user data into a single structure
+type NotificationPayload struct {
+ Action string `json:"action"`
+ Options frontend.NotificationOptions `json:"payload,omitempty"`
+}
+
+func (f *Frontend) InitializeNotifications() error {
+ categoriesLock.Lock()
+ defer categoriesLock.Unlock()
+ categories = make(map[string]frontend.NotificationCategory)
+
+ exe, err := os.Executable()
+ if err != nil {
+ return fmt.Errorf("failed to get executable: %w", err)
+ }
+ exePath = exe
+ appName = filepath.Base(exePath)
+
+ appGUID, err = getGUID()
+ if err != nil {
+ return err
+ }
+
+ iconPath = filepath.Join(os.TempDir(), appName+appGUID+".png")
+
+ // Create the registry key for the toast activator
+ key, _, err := registry.CreateKey(registry.CURRENT_USER,
+ `Software\Classes\CLSID\`+appGUID+`\LocalServer32`, registry.ALL_ACCESS)
+ if err != nil {
+ return fmt.Errorf("failed to create CLSID key: %w", err)
+ }
+ defer key.Close()
+
+ if err := key.SetStringValue("", fmt.Sprintf("\"%s\" %%1", exePath)); err != nil {
+ return fmt.Errorf("failed to set CLSID server path: %w", err)
+ }
+
+ toast.SetAppData(toast.AppData{
+ AppID: appName,
+ GUID: appGUID,
+ IconPath: iconPath,
+ ActivationExe: exePath,
+ })
+
+ toast.SetActivationCallback(func(args string, data []toast.UserData) {
+ result := frontend.NotificationResult{}
+
+ actionIdentifier, options, err := parseNotificationResponse(args)
+
+ if err != nil {
+ result.Error = err
+ } else {
+ // Subtitle is retained but was not shown with the notification
+ response := frontend.NotificationResponse{
+ ID: options.ID,
+ ActionIdentifier: actionIdentifier,
+ Title: options.Title,
+ Subtitle: options.Subtitle,
+ Body: options.Body,
+ CategoryID: options.CategoryID,
+ UserInfo: options.Data,
+ }
+
+ if userText, found := getUserText(data); found {
+ response.UserText = userText
+ }
+
+ result.Response = response
+ }
+
+ handleNotificationResult(result)
+ })
+
+ // Register the COM class factory for toast activation.
+ // This is required for Windows to activate the app when users interact with notifications.
+ // The go-toast library's SetAppData and SetActivationCallback handle the callback setup,
+ // but the COM class factory registration is not exposed via public APIs, so we use
+ // go:linkname to access the internal registerClassFactory function.
+ if err := registerToastClassFactory(wintoast.ClassFactory); err != nil {
+ return fmt.Errorf("CoRegisterClassObject failed: %w", err)
+ }
+
+ return loadCategoriesFromRegistry()
+}
+
+// registerToastClassFactory registers the COM class factory required for Windows toast notification activation.
+// This function uses go:linkname to access the unexported registerClassFactory function from go-toast.
+// The class factory is necessary for Windows COM activation when users click notification actions.
+// Without this registration, notification actions will not activate the application.
+//
+// This is a workaround until go-toast exports this functionality via a public API.
+// See: https://git.sr.ht/~jackmordaunt/go-toast
+//
+//go:linkname registerToastClassFactory git.sr.ht/~jackmordaunt/go-toast/v2/wintoast.registerClassFactory
+func registerToastClassFactory(factory *wintoast.IClassFactory) error
+
+// CleanupNotifications is a Windows stub that does nothing.
+// (Linux-specific cleanup)
+func (f *Frontend) CleanupNotifications() {
+ // No cleanup needed on Windows
+}
+
+func (f *Frontend) IsNotificationAvailable() bool {
+ return true
+}
+
+func (f *Frontend) RequestNotificationAuthorization() (bool, error) {
+ return true, nil
+}
+
+func (f *Frontend) CheckNotificationAuthorization() (bool, error) {
+ return true, nil
+}
+
+// SendNotification sends a basic notification with a name, title, and body. All other options are ignored on Windows.
+// (subtitle is only available on macOS and Linux)
+func (f *Frontend) SendNotification(options frontend.NotificationOptions) error {
+ if err := f.saveIconToDir(); err != nil {
+ f.logger.Warning("Error saving icon: %v", err)
+ }
+
+ n := toast.Notification{
+ Title: options.Title,
+ Body: options.Body,
+ ActivationType: toast.Foreground,
+ ActivationArguments: DefaultActionIdentifier,
+ }
+
+ encodedPayload, err := encodePayload(DefaultActionIdentifier, options)
+ if err != nil {
+ return fmt.Errorf("failed to encode notification payload: %w", err)
+ }
+ n.ActivationArguments = encodedPayload
+
+ return n.Push()
+}
+
+// SendNotificationWithActions sends a notification with additional actions and inputs.
+// A NotificationCategory must be registered with RegisterNotificationCategory first. The `CategoryID` must match the registered category.
+// If a NotificationCategory is not registered a basic notification will be sent.
+// (subtitle is only available on macOS and Linux)
+func (f *Frontend) SendNotificationWithActions(options frontend.NotificationOptions) error {
+ if err := f.saveIconToDir(); err != nil {
+ f.logger.Warning("Error saving icon: %v", err)
+ }
+
+ categoriesLock.RLock()
+ nCategory, categoryExists := categories[options.CategoryID]
+ categoriesLock.RUnlock()
+
+ if options.CategoryID == "" || !categoryExists {
+ f.logger.Warning("Category '%s' not found, sending basic notification without actions", options.CategoryID)
+ return f.SendNotification(options)
+ }
+
+ n := toast.Notification{
+ Title: options.Title,
+ Body: options.Body,
+ ActivationType: toast.Foreground,
+ ActivationArguments: DefaultActionIdentifier,
+ }
+
+ for _, action := range nCategory.Actions {
+ n.Actions = append(n.Actions, toast.Action{
+ Content: action.Title,
+ Arguments: action.ID,
+ })
+ }
+
+ if nCategory.HasReplyField {
+ n.Inputs = append(n.Inputs, toast.Input{
+ ID: "userText",
+ Placeholder: nCategory.ReplyPlaceholder,
+ })
+
+ n.Actions = append(n.Actions, toast.Action{
+ Content: nCategory.ReplyButtonTitle,
+ Arguments: "TEXT_REPLY",
+ InputID: "userText",
+ })
+ }
+
+ encodedPayload, err := encodePayload(n.ActivationArguments, options)
+ if err != nil {
+ return fmt.Errorf("failed to encode notification payload: %w", err)
+ }
+ n.ActivationArguments = encodedPayload
+
+ for index := range n.Actions {
+ encodedPayload, err := encodePayload(n.Actions[index].Arguments, options)
+ if err != nil {
+ return fmt.Errorf("failed to encode notification payload: %w", err)
+ }
+ n.Actions[index].Arguments = encodedPayload
+ }
+
+ return n.Push()
+}
+
+// RegisterNotificationCategory registers a new NotificationCategory to be used with SendNotificationWithActions.
+// Registering a category with the same name as a previously registered NotificationCategory will override it.
+func (f *Frontend) RegisterNotificationCategory(category frontend.NotificationCategory) error {
+ categoriesLock.Lock()
+ defer categoriesLock.Unlock()
+
+ categories[category.ID] = frontend.NotificationCategory{
+ ID: category.ID,
+ Actions: category.Actions,
+ HasReplyField: category.HasReplyField,
+ ReplyPlaceholder: category.ReplyPlaceholder,
+ ReplyButtonTitle: category.ReplyButtonTitle,
+ }
+
+ return saveCategoriesToRegistry()
+}
+
+// RemoveNotificationCategory removes a previously registered NotificationCategory.
+func (f *Frontend) RemoveNotificationCategory(categoryId string) error {
+ categoriesLock.Lock()
+ defer categoriesLock.Unlock()
+
+ delete(categories, categoryId)
+
+ return saveCategoriesToRegistry()
+}
+
+// RemoveAllPendingNotifications is a Windows stub that always returns nil.
+// (macOS and Linux only)
+func (f *Frontend) RemoveAllPendingNotifications() error {
+ return nil
+}
+
+// RemovePendingNotification is a Windows stub that always returns nil.
+// (macOS and Linux only)
+func (f *Frontend) RemovePendingNotification(_ string) error {
+ return nil
+}
+
+// RemoveAllDeliveredNotifications is a Windows stub that always returns nil.
+// (macOS and Linux only)
+func (f *Frontend) RemoveAllDeliveredNotifications() error {
+ return nil
+}
+
+// RemoveDeliveredNotification is a Windows stub that always returns nil.
+// (macOS and Linux only)
+func (f *Frontend) RemoveDeliveredNotification(_ string) error {
+ return nil
+}
+
+// RemoveNotification is a Windows stub that always returns nil.
+// (Linux-specific)
+func (f *Frontend) RemoveNotification(identifier string) error {
+ return nil
+}
+
+func (f *Frontend) OnNotificationResponse(callback func(result frontend.NotificationResult)) {
+ callbackLock.Lock()
+ defer callbackLock.Unlock()
+
+ notificationResultCallback = callback
+}
+
+func (f *Frontend) saveIconToDir() error {
+ iconOnce.Do(func() {
+ hIcon := w32.ExtractIcon(exePath, 0)
+ if hIcon == 0 {
+ iconErr = fmt.Errorf("ExtractIcon failed for %s", exePath)
+ return
+ }
+ defer w32.DestroyIcon(hIcon)
+ iconErr = winc.SaveHIconAsPNG(hIcon, iconPath)
+ })
+ return iconErr
+}
+
+func saveCategoriesToRegistry() error {
+ // We assume lock is held by caller
+
+ registryPath := fmt.Sprintf(NotificationCategoriesRegistryPath, appName)
+
+ key, _, err := registry.CreateKey(
+ registry.CURRENT_USER,
+ registryPath,
+ registry.ALL_ACCESS,
+ )
+ if err != nil {
+ return err
+ }
+ defer key.Close()
+
+ data, err := json.Marshal(categories)
+ if err != nil {
+ return err
+ }
+
+ return key.SetStringValue(NotificationCategoriesRegistryKey, string(data))
+}
+
+func loadCategoriesFromRegistry() error {
+ // We assume lock is held by caller
+
+ registryPath := fmt.Sprintf(NotificationCategoriesRegistryPath, appName)
+
+ key, err := registry.OpenKey(
+ registry.CURRENT_USER,
+ registryPath,
+ registry.QUERY_VALUE,
+ )
+ if err != nil {
+ if err == registry.ErrNotExist {
+ // Not an error, no saved categories
+ return nil
+ }
+ return fmt.Errorf("failed to open registry key: %w", err)
+ }
+ defer key.Close()
+
+ data, _, err := key.GetStringValue(NotificationCategoriesRegistryKey)
+ if err != nil {
+ if err == registry.ErrNotExist {
+ // No value yet, but key exists
+ return nil
+ }
+ return fmt.Errorf("failed to read categories from registry: %w", err)
+ }
+
+ _categories := make(map[string]frontend.NotificationCategory)
+ if err := json.Unmarshal([]byte(data), &_categories); err != nil {
+ return fmt.Errorf("failed to parse notification categories from registry: %w", err)
+ }
+
+ categories = _categories
+
+ return nil
+}
+
+func getUserText(data []toast.UserData) (string, bool) {
+ for _, d := range data {
+ if d.Key == "userText" {
+ return d.Value, true
+ }
+ }
+ return "", false
+}
+
+// encodePayload combines an action ID and user data into a single encoded string
+func encodePayload(actionID string, options frontend.NotificationOptions) (string, error) {
+ payload := NotificationPayload{
+ Action: actionID,
+ Options: options,
+ }
+
+ jsonData, err := json.Marshal(payload)
+ if err != nil {
+ return actionID, err
+ }
+
+ encodedPayload := base64.StdEncoding.EncodeToString(jsonData)
+ return encodedPayload, nil
+}
+
+// decodePayload extracts the action ID and user data from an encoded payload
+func decodePayload(encodedString string) (string, frontend.NotificationOptions, error) {
+ jsonData, err := base64.StdEncoding.DecodeString(encodedString)
+ if err != nil {
+ return encodedString, frontend.NotificationOptions{}, fmt.Errorf("failed to decode base64 payload: %w", err)
+ }
+
+ var payload NotificationPayload
+ if err := json.Unmarshal(jsonData, &payload); err != nil {
+ return encodedString, frontend.NotificationOptions{}, fmt.Errorf("failed to unmarshal notification payload: %w", err)
+ }
+
+ return payload.Action, payload.Options, nil
+}
+
+// parseNotificationResponse updated to use structured payload decoding
+func parseNotificationResponse(response string) (action string, options frontend.NotificationOptions, err error) {
+ actionID, options, err := decodePayload(response)
+
+ if err != nil {
+ log.Printf("Warning: Failed to decode notification response: %v", err)
+ return response, frontend.NotificationOptions{}, err
+ }
+
+ return actionID, options, nil
+}
+
+func handleNotificationResult(result frontend.NotificationResult) {
+ callbackLock.RLock()
+ callback := notificationResultCallback
+ callbackLock.RUnlock()
+
+ if callback != nil {
+ go func() {
+ defer func() {
+ if r := recover(); r != nil {
+ // Log panic but don't crash the app
+ fmt.Fprintf(os.Stderr, "panic in notification callback: %v\n", r)
+ }
+ }()
+ callback(result)
+ }()
+ }
+}
+
+// Helper functions
+
+func getGUID() (string, error) {
+ keyPath := ToastRegistryPath + appName
+
+ k, err := registry.OpenKey(registry.CURRENT_USER, keyPath, registry.QUERY_VALUE)
+ if err == nil {
+ guid, _, err := k.GetStringValue(ToastRegistryGuidKey)
+ k.Close()
+ if err == nil && guid != "" {
+ return guid, nil
+ }
+ }
+
+ guid := generateGUID()
+
+ k, _, err = registry.CreateKey(registry.CURRENT_USER, keyPath, registry.WRITE)
+ if err != nil {
+ return "", fmt.Errorf("failed to create registry key: %w", err)
+ }
+ defer k.Close()
+
+ if err := k.SetStringValue(ToastRegistryGuidKey, guid); err != nil {
+ return "", fmt.Errorf("failed to write GUID to registry: %w", err)
+ }
+
+ return guid, nil
+}
+
+func generateGUID() string {
+ guid := uuid.New()
+ return fmt.Sprintf("{%s}", guid.String())
+}
diff --git a/v2/internal/frontend/desktop/windows/winc/icon.go b/v2/internal/frontend/desktop/windows/winc/icon.go
index 6a3e1a391..94e9198d6 100644
--- a/v2/internal/frontend/desktop/windows/winc/icon.go
+++ b/v2/internal/frontend/desktop/windows/winc/icon.go
@@ -10,11 +10,86 @@ package winc
import (
"errors"
"fmt"
+ "image"
+ "image/png"
+ "os"
"syscall"
+ "unsafe"
"github.com/wailsapp/wails/v2/internal/frontend/desktop/windows/winc/w32"
)
+var (
+ user32 = syscall.NewLazyDLL("user32.dll")
+ gdi32 = syscall.NewLazyDLL("gdi32.dll")
+ procGetIconInfo = user32.NewProc("GetIconInfo")
+ procDeleteObject = gdi32.NewProc("DeleteObject")
+ procGetObject = gdi32.NewProc("GetObjectW")
+ procGetDIBits = gdi32.NewProc("GetDIBits")
+ procCreateCompatibleDC = gdi32.NewProc("CreateCompatibleDC")
+ procSelectObject = gdi32.NewProc("SelectObject")
+ procDeleteDC = gdi32.NewProc("DeleteDC")
+)
+
+func init() {
+ // Validate DLL loads at initialization time to surface missing APIs early
+ if err := user32.Load(); err != nil {
+ panic(fmt.Sprintf("failed to load user32.dll: %v", err))
+ }
+ if err := gdi32.Load(); err != nil {
+ panic(fmt.Sprintf("failed to load gdi32.dll: %v", err))
+ }
+}
+
+// ICONINFO mirrors the Win32 ICONINFO struct
+type ICONINFO struct {
+ FIcon int32
+ XHotspot uint32
+ YHotspot uint32
+ HbmMask uintptr
+ HbmColor uintptr
+}
+
+// http://msdn.microsoft.com/en-us/library/windows/desktop/dd183376.aspx
+type BITMAPINFOHEADER struct {
+ BiSize uint32
+ BiWidth int32
+ BiHeight int32
+ BiPlanes uint16
+ BiBitCount uint16
+ BiCompression uint32
+ BiSizeImage uint32
+ BiXPelsPerMeter int32
+ BiYPelsPerMeter int32
+ BiClrUsed uint32
+ BiClrImportant uint32
+}
+
+// http://msdn.microsoft.com/en-us/library/windows/desktop/dd162938.aspx
+type RGBQUAD struct {
+ RgbBlue byte
+ RgbGreen byte
+ RgbRed byte
+ RgbReserved byte
+}
+
+// http://msdn.microsoft.com/en-us/library/windows/desktop/dd183375.aspx
+type BITMAPINFO struct {
+ BmiHeader BITMAPINFOHEADER
+ BmiColors *RGBQUAD
+}
+
+// http://msdn.microsoft.com/en-us/library/windows/desktop/dd183371.aspx
+type BITMAP struct {
+ BmType int32
+ BmWidth int32
+ BmHeight int32
+ BmWidthBytes int32
+ BmPlanes uint16
+ BmBitsPixel uint16
+ BmBits unsafe.Pointer
+}
+
type Icon struct {
handle w32.HICON
}
@@ -46,6 +121,95 @@ func ExtractIcon(fileName string, index int) (*Icon, error) {
return ico, err
}
+func SaveHIconAsPNG(hIcon w32.HICON, filePath string) error {
+ // Get icon info
+ var iconInfo ICONINFO
+ ret, _, err := procGetIconInfo.Call(
+ uintptr(hIcon),
+ uintptr(unsafe.Pointer(&iconInfo)),
+ )
+ if ret == 0 {
+ return err
+ }
+ defer procDeleteObject.Call(uintptr(iconInfo.HbmMask))
+ defer procDeleteObject.Call(uintptr(iconInfo.HbmColor))
+
+ // Get bitmap info
+ var bmp BITMAP
+ ret, _, err = procGetObject.Call(
+ uintptr(iconInfo.HbmColor),
+ unsafe.Sizeof(bmp),
+ uintptr(unsafe.Pointer(&bmp)),
+ )
+ if ret == 0 {
+ return err
+ }
+
+ // Get screen DC for GetDIBits (bitmap must not be selected into a DC)
+ screenDC := w32.GetDC(0)
+ if screenDC == 0 {
+ return fmt.Errorf("failed to get screen DC")
+ }
+ defer w32.ReleaseDC(0, screenDC)
+
+ // Prepare bitmap info header
+ var bi BITMAPINFO
+ bi.BmiHeader.BiSize = uint32(unsafe.Sizeof(bi.BmiHeader))
+ bi.BmiHeader.BiWidth = bmp.BmWidth
+ bi.BmiHeader.BiHeight = bmp.BmHeight
+ bi.BmiHeader.BiPlanes = 1
+ bi.BmiHeader.BiBitCount = 32
+ bi.BmiHeader.BiCompression = w32.BI_RGB
+
+ // Allocate memory for bitmap bits
+ width, height := int(bmp.BmWidth), int(bmp.BmHeight)
+ bufferSize := width * height * 4
+ bits := make([]byte, bufferSize)
+
+ // Get bitmap bits using screen DC (bitmap must not be selected into any DC)
+ ret, _, err = procGetDIBits.Call(
+ uintptr(screenDC),
+ uintptr(iconInfo.HbmColor),
+ 0,
+ uintptr(bmp.BmHeight),
+ uintptr(unsafe.Pointer(&bits[0])),
+ uintptr(unsafe.Pointer(&bi)),
+ w32.DIB_RGB_COLORS,
+ )
+ if ret == 0 {
+ return fmt.Errorf("failed to get bitmap bits: %w", err)
+ }
+
+ // Create Go image
+ img := image.NewRGBA(image.Rect(0, 0, width, height))
+
+ // Convert DIB to RGBA
+ for y := 0; y < height; y++ {
+ for x := 0; x < width; x++ {
+ // DIB is bottom-up, so we need to invert Y
+ dibIndex := ((height-1-y)*width + x) * 4
+ // RGBA image is top-down
+ imgIndex := (y*width + x) * 4
+
+ // BGRA to RGBA
+ img.Pix[imgIndex] = bits[dibIndex+2] // R
+ img.Pix[imgIndex+1] = bits[dibIndex+1] // G
+ img.Pix[imgIndex+2] = bits[dibIndex] // B
+ img.Pix[imgIndex+3] = bits[dibIndex+3] // A
+ }
+ }
+
+ // Create output file
+ outFile, err := os.Create(filePath)
+ if err != nil {
+ return err
+ }
+ defer outFile.Close()
+
+ // Encode and save the image
+ return png.Encode(outFile, img)
+}
+
func (ic *Icon) Destroy() bool {
return w32.DestroyIcon(ic.handle)
}
diff --git a/v2/internal/frontend/dispatcher/systemcalls.go b/v2/internal/frontend/dispatcher/systemcalls.go
index b090a416e..a13eb03b9 100644
--- a/v2/internal/frontend/dispatcher/systemcalls.go
+++ b/v2/internal/frontend/dispatcher/systemcalls.go
@@ -61,6 +61,102 @@ func (d *Dispatcher) processSystemCall(payload callMessage, sender frontend.Fron
return false, err
}
return true, nil
+ case "InitializeNotifications":
+ err := sender.InitializeNotifications()
+ return nil, err
+ case "CleanupNotifications":
+ sender.CleanupNotifications()
+ return nil, nil
+ case "IsNotificationAvailable":
+ return sender.IsNotificationAvailable(), nil
+ case "RequestNotificationAuthorization":
+ authorized, err := sender.RequestNotificationAuthorization()
+ if err != nil {
+ return nil, err
+ }
+ return authorized, nil
+ case "CheckNotificationAuthorization":
+ authorized, err := sender.CheckNotificationAuthorization()
+ if err != nil {
+ return nil, err
+ }
+ return authorized, nil
+ case "SendNotification":
+ if len(payload.Args) < 1 {
+ return nil, errors.New("empty argument, cannot send notification")
+ }
+ var options frontend.NotificationOptions
+ if err := json.Unmarshal(payload.Args[0], &options); err != nil {
+ return nil, err
+ }
+ err := sender.SendNotification(options)
+ return nil, err
+ case "SendNotificationWithActions":
+ if len(payload.Args) < 1 {
+ return nil, errors.New("empty argument, cannot send notification")
+ }
+ var options frontend.NotificationOptions
+ if err := json.Unmarshal(payload.Args[0], &options); err != nil {
+ return nil, err
+ }
+ err := sender.SendNotificationWithActions(options)
+ return nil, err
+ case "RegisterNotificationCategory":
+ if len(payload.Args) < 1 {
+ return nil, errors.New("empty argument, cannot register category")
+ }
+ var category frontend.NotificationCategory
+ if err := json.Unmarshal(payload.Args[0], &category); err != nil {
+ return nil, err
+ }
+ err := sender.RegisterNotificationCategory(category)
+ return nil, err
+ case "RemoveNotificationCategory":
+ if len(payload.Args) < 1 {
+ return nil, errors.New("empty argument, cannot remove category")
+ }
+ var categoryId string
+ if err := json.Unmarshal(payload.Args[0], &categoryId); err != nil {
+ return nil, err
+ }
+ err := sender.RemoveNotificationCategory(categoryId)
+ return nil, err
+ case "RemoveAllPendingNotifications":
+ err := sender.RemoveAllPendingNotifications()
+ return nil, err
+ case "RemovePendingNotification":
+ if len(payload.Args) < 1 {
+ return nil, errors.New("empty argument, cannot remove notification")
+ }
+ var identifier string
+ if err := json.Unmarshal(payload.Args[0], &identifier); err != nil {
+ return nil, err
+ }
+ err := sender.RemovePendingNotification(identifier)
+ return nil, err
+ case "RemoveAllDeliveredNotifications":
+ err := sender.RemoveAllDeliveredNotifications()
+ return nil, err
+ case "RemoveDeliveredNotification":
+ if len(payload.Args) < 1 {
+ return nil, errors.New("empty argument, cannot remove notification")
+ }
+ var identifier string
+ if err := json.Unmarshal(payload.Args[0], &identifier); err != nil {
+ return nil, err
+ }
+ err := sender.RemoveDeliveredNotification(identifier)
+ return nil, err
+ case "RemoveNotification":
+ if len(payload.Args) < 1 {
+ return nil, errors.New("empty argument, cannot remove notification")
+ }
+ var identifier string
+ if err := json.Unmarshal(payload.Args[0], &identifier); err != nil {
+ return nil, err
+ }
+ err := sender.RemoveNotification(identifier)
+ return nil, err
default:
return nil, fmt.Errorf("unknown systemcall message: %s", payload.Name)
}
diff --git a/v2/internal/frontend/frontend.go b/v2/internal/frontend/frontend.go
index 6b2ccbcae..873b61dc7 100644
--- a/v2/internal/frontend/frontend.go
+++ b/v2/internal/frontend/frontend.go
@@ -76,6 +76,51 @@ type MessageDialogOptions struct {
Icon []byte
}
+// NotificationOptions contains configuration for a notification.
+type NotificationOptions struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ Subtitle string `json:"subtitle,omitempty"` // (macOS and Linux only)
+ Body string `json:"body,omitempty"`
+ CategoryID string `json:"categoryId,omitempty"`
+ Data map[string]interface{} `json:"data,omitempty"`
+}
+
+// NotificationAction represents an action button for a notification.
+type NotificationAction struct {
+ ID string `json:"id,omitempty"`
+ Title string `json:"title,omitempty"`
+ Destructive bool `json:"destructive,omitempty"` // (macOS-specific)
+}
+
+// NotificationCategory groups actions for notifications.
+type NotificationCategory struct {
+ ID string `json:"id,omitempty"`
+ Actions []NotificationAction `json:"actions,omitempty"`
+ HasReplyField bool `json:"hasReplyField,omitempty"`
+ ReplyPlaceholder string `json:"replyPlaceholder,omitempty"`
+ ReplyButtonTitle string `json:"replyButtonTitle,omitempty"`
+}
+
+// NotificationResponse represents the response sent by interacting with a notification.
+type NotificationResponse struct {
+ ID string `json:"id,omitempty"`
+ ActionIdentifier string `json:"actionIdentifier,omitempty"`
+ CategoryID string `json:"categoryId,omitempty"` // Consistent with NotificationOptions
+ Title string `json:"title,omitempty"`
+ Subtitle string `json:"subtitle,omitempty"` // (macOS and Linux only)
+ Body string `json:"body,omitempty"`
+ UserText string `json:"userText,omitempty"`
+ UserInfo map[string]interface{} `json:"userInfo,omitempty"`
+}
+
+// NotificationResult represents the result of a notification response,
+// returning the response or any errors that occurred.
+type NotificationResult struct {
+ Response NotificationResponse
+ Error error
+}
+
type Frontend interface {
Run(ctx context.Context) error
RunMainLoop()
@@ -139,4 +184,21 @@ type Frontend interface {
// Clipboard
ClipboardGetText() (string, error)
ClipboardSetText(text string) error
+
+ // Notifications
+ InitializeNotifications() error
+ CleanupNotifications()
+ IsNotificationAvailable() bool
+ RequestNotificationAuthorization() (bool, error)
+ CheckNotificationAuthorization() (bool, error)
+ OnNotificationResponse(callback func(result NotificationResult))
+ SendNotification(options NotificationOptions) error
+ SendNotificationWithActions(options NotificationOptions) error
+ RegisterNotificationCategory(category NotificationCategory) error
+ RemoveNotificationCategory(categoryId string) error
+ RemoveAllPendingNotifications() error
+ RemovePendingNotification(identifier string) error
+ RemoveAllDeliveredNotifications() error
+ RemoveDeliveredNotification(identifier string) error
+ RemoveNotification(identifier string) error
}
diff --git a/v2/internal/frontend/runtime/desktop/main.js b/v2/internal/frontend/runtime/desktop/main.js
index 3fda7ef36..405d5f60d 100644
--- a/v2/internal/frontend/runtime/desktop/main.js
+++ b/v2/internal/frontend/runtime/desktop/main.js
@@ -27,6 +27,7 @@ import * as Browser from "./browser";
import * as Clipboard from "./clipboard";
import * as DragAndDrop from "./draganddrop";
import * as ContextMenu from "./contextmenu";
+import * as Notifications from "./notifications";
export function Quit() {
window.WailsInvoke('Q');
@@ -52,6 +53,7 @@ window.runtime = {
...Screen,
...Clipboard,
...DragAndDrop,
+ ...Notifications,
EventsOn,
EventsOnce,
EventsOnMultiple,
diff --git a/v2/internal/frontend/runtime/desktop/notifications.js b/v2/internal/frontend/runtime/desktop/notifications.js
new file mode 100644
index 000000000..25c01bb34
--- /dev/null
+++ b/v2/internal/frontend/runtime/desktop/notifications.js
@@ -0,0 +1,200 @@
+/*
+ _ __ _ __
+| | / /___ _(_) /____
+| | /| / / __ `/ / / ___/
+| |/ |/ / /_/ / / (__ )
+|__/|__/\__,_/_/_/____/
+The electron alternative for Go
+(c) Lea Anthony 2019-present
+*/
+/* jshint esversion: 9 */
+
+import {Call} from "./calls";
+
+/**
+ * Initialize the notification service for the application.
+ * This must be called before sending any notifications.
+ * On macOS, this also ensures the notification delegate is properly initialized.
+ *
+ * @export
+ * @return {Promise}
+ */
+export function InitializeNotifications() {
+ return Call(":wails:InitializeNotifications");
+}
+
+/**
+ * Clean up notification resources and release any held connections.
+ * This should be called when shutting down the application to properly release resources
+ * (primarily needed on Linux to close D-Bus connections).
+ *
+ * @export
+ * @return {Promise}
+ */
+export function CleanupNotifications() {
+ return Call(":wails:CleanupNotifications");
+}
+
+/**
+ * Check if notifications are available on the current platform.
+ *
+ * @export
+ * @return {Promise} True if notifications are available, false otherwise
+ */
+export function IsNotificationAvailable() {
+ return Call(":wails:IsNotificationAvailable");
+}
+
+/**
+ * Request notification authorization from the user.
+ * On macOS, this prompts the user to allow notifications.
+ * On other platforms, this always returns true.
+ *
+ * @export
+ * @return {Promise} True if authorization was granted, false otherwise
+ */
+export function RequestNotificationAuthorization() {
+ return Call(":wails:RequestNotificationAuthorization");
+}
+
+/**
+ * Check the current notification authorization status.
+ * On macOS, this checks if the app has notification permissions.
+ * On other platforms, this always returns true.
+ *
+ * @export
+ * @return {Promise} True if authorized, false otherwise
+ */
+export function CheckNotificationAuthorization() {
+ return Call(":wails:CheckNotificationAuthorization");
+}
+
+/**
+ * Send a basic notification with the given options.
+ * The notification will display with the provided title, subtitle (if supported), and body text.
+ *
+ * @export
+ * @param {Object} options - Notification options
+ * @param {string} options.id - Unique identifier for the notification
+ * @param {string} options.title - Notification title
+ * @param {string} [options.subtitle] - Notification subtitle (macOS and Linux only)
+ * @param {string} [options.body] - Notification body text
+ * @param {string} [options.categoryId] - Category ID for action buttons (requires SendNotificationWithActions)
+ * @param {Object} [options.data] - Additional user data to attach to the notification
+ * @return {Promise}
+ */
+export function SendNotification(options) {
+ return Call(":wails:SendNotification", [options]);
+}
+
+/**
+ * Send a notification with action buttons.
+ * A NotificationCategory must be registered first using RegisterNotificationCategory.
+ * The options.categoryId must match a previously registered category ID.
+ * If the category is not found, a basic notification will be sent instead.
+ *
+ * @export
+ * @param {Object} options - Notification options
+ * @param {string} options.id - Unique identifier for the notification
+ * @param {string} options.title - Notification title
+ * @param {string} [options.subtitle] - Notification subtitle (macOS and Linux only)
+ * @param {string} [options.body] - Notification body text
+ * @param {string} options.categoryId - Category ID that matches a registered category
+ * @param {Object} [options.data] - Additional user data to attach to the notification
+ * @return {Promise}
+ */
+export function SendNotificationWithActions(options) {
+ return Call(":wails:SendNotificationWithActions", [options]);
+}
+
+/**
+ * Register a notification category that can be used with SendNotificationWithActions.
+ * Categories define the action buttons and optional reply fields that will appear on notifications.
+ * Registering a category with the same ID as a previously registered category will override it.
+ *
+ * @export
+ * @param {Object} category - Notification category definition
+ * @param {string} category.id - Unique identifier for the category
+ * @param {Array