diff --git a/v2/examples/tray-icon/.gitignore b/v2/examples/tray-icon/.gitignore
new file mode 100644
index 000000000..a11bbf414
--- /dev/null
+++ b/v2/examples/tray-icon/.gitignore
@@ -0,0 +1,4 @@
+build/bin
+node_modules
+frontend/dist
+frontend/wailsjs
diff --git a/v2/examples/tray-icon/README.md b/v2/examples/tray-icon/README.md
new file mode 100644
index 000000000..e299d7870
--- /dev/null
+++ b/v2/examples/tray-icon/README.md
@@ -0,0 +1,19 @@
+# README
+
+## About
+
+This example tests tray icon behavior and runtime tray updates.
+
+## Running
+
+From `v2/examples/tray-icon`:
+
+```bash
+go run -tags "desktop,production,webkit2_41" .
+```
+
+Or from `v2`:
+
+```bash
+go run -tags "desktop,production,webkit2_41" ./examples/tray-icon
+```
diff --git a/v2/examples/tray-icon/app.go b/v2/examples/tray-icon/app.go
new file mode 100644
index 000000000..b2a2fadab
--- /dev/null
+++ b/v2/examples/tray-icon/app.go
@@ -0,0 +1,16 @@
+package main
+
+import "context"
+
+// App stores runtime context for tray callbacks.
+type App struct {
+ ctx context.Context
+}
+
+func NewApp() *App {
+ return &App{}
+}
+
+func (a *App) startup(ctx context.Context) {
+ a.ctx = ctx
+}
diff --git a/v2/examples/tray-icon/build/README.md b/v2/examples/tray-icon/build/README.md
new file mode 100644
index 000000000..1ae2f677f
--- /dev/null
+++ b/v2/examples/tray-icon/build/README.md
@@ -0,0 +1,35 @@
+# Build Directory
+
+The build directory is used to house all the build files and assets for your application.
+
+The structure is:
+
+* bin - Output directory
+* darwin - macOS specific files
+* windows - Windows specific files
+
+## Mac
+
+The `darwin` directory holds files specific to Mac builds.
+These may be customised and used as part of the build. To return these files to the default state, simply delete them
+and
+build with `wails build`.
+
+The directory contains the following files:
+
+- `Info.plist` - the main plist file used for Mac builds. It is used when building using `wails build`.
+- `Info.dev.plist` - same as the main plist file but used when building using `wails dev`.
+
+## Windows
+
+The `windows` directory contains the manifest and rc files used when building with `wails build`.
+These may be customised for your application. To return these files to the default state, simply delete them and
+build with `wails build`.
+
+- `icon.ico` - The icon used for the application. This is used when building using `wails build`. If you wish to
+ use a different icon, simply replace this file with your own. If it is missing, a new `icon.ico` file
+ will be created using the `appicon.png` file in the build directory.
+- `installer/*` - The files used to create the Windows installer. These are used when building using `wails build`.
+- `info.json` - Application details used for Windows builds. The data here will be used by the Windows installer,
+ as well as the application itself (right click the exe -> properties -> details)
+- `wails.exe.manifest` - The main application manifest file.
\ No newline at end of file
diff --git a/v2/examples/tray-icon/build/appicon.png b/v2/examples/tray-icon/build/appicon.png
new file mode 100644
index 000000000..63617fe4f
Binary files /dev/null and b/v2/examples/tray-icon/build/appicon.png differ
diff --git a/v2/examples/tray-icon/build/darwin/Info.dev.plist b/v2/examples/tray-icon/build/darwin/Info.dev.plist
new file mode 100644
index 000000000..14121ef7c
--- /dev/null
+++ b/v2/examples/tray-icon/build/darwin/Info.dev.plist
@@ -0,0 +1,68 @@
+
+
+
+ CFBundlePackageType
+ APPL
+ CFBundleName
+ {{.Info.ProductName}}
+ CFBundleExecutable
+ {{.OutputFilename}}
+ CFBundleIdentifier
+ com.wails.{{.Name}}
+ CFBundleVersion
+ {{.Info.ProductVersion}}
+ CFBundleGetInfoString
+ {{.Info.Comments}}
+ CFBundleShortVersionString
+ {{.Info.ProductVersion}}
+ CFBundleIconFile
+ iconfile
+ LSMinimumSystemVersion
+ 10.13.0
+ NSHighResolutionCapable
+ true
+ NSHumanReadableCopyright
+ {{.Info.Copyright}}
+ {{if .Info.FileAssociations}}
+ CFBundleDocumentTypes
+
+ {{range .Info.FileAssociations}}
+
+ CFBundleTypeExtensions
+
+ {{.Ext}}
+
+ CFBundleTypeName
+ {{.Name}}
+ CFBundleTypeRole
+ {{.Role}}
+ CFBundleTypeIconFile
+ {{.IconName}}
+
+ {{end}}
+
+ {{end}}
+ {{if .Info.Protocols}}
+ CFBundleURLTypes
+
+ {{range .Info.Protocols}}
+
+ CFBundleURLName
+ com.wails.{{.Scheme}}
+ CFBundleURLSchemes
+
+ {{.Scheme}}
+
+ CFBundleTypeRole
+ {{.Role}}
+
+ {{end}}
+
+ {{end}}
+ NSAppTransportSecurity
+
+ NSAllowsLocalNetworking
+
+
+
+
diff --git a/v2/examples/tray-icon/build/darwin/Info.plist b/v2/examples/tray-icon/build/darwin/Info.plist
new file mode 100644
index 000000000..d17a7475c
--- /dev/null
+++ b/v2/examples/tray-icon/build/darwin/Info.plist
@@ -0,0 +1,63 @@
+
+
+
+ CFBundlePackageType
+ APPL
+ CFBundleName
+ {{.Info.ProductName}}
+ CFBundleExecutable
+ {{.OutputFilename}}
+ CFBundleIdentifier
+ com.wails.{{.Name}}
+ CFBundleVersion
+ {{.Info.ProductVersion}}
+ CFBundleGetInfoString
+ {{.Info.Comments}}
+ CFBundleShortVersionString
+ {{.Info.ProductVersion}}
+ CFBundleIconFile
+ iconfile
+ LSMinimumSystemVersion
+ 10.13.0
+ NSHighResolutionCapable
+ true
+ NSHumanReadableCopyright
+ {{.Info.Copyright}}
+ {{if .Info.FileAssociations}}
+ CFBundleDocumentTypes
+
+ {{range .Info.FileAssociations}}
+
+ CFBundleTypeExtensions
+
+ {{.Ext}}
+
+ CFBundleTypeName
+ {{.Name}}
+ CFBundleTypeRole
+ {{.Role}}
+ CFBundleTypeIconFile
+ {{.IconName}}
+
+ {{end}}
+
+ {{end}}
+ {{if .Info.Protocols}}
+ CFBundleURLTypes
+
+ {{range .Info.Protocols}}
+
+ CFBundleURLName
+ com.wails.{{.Scheme}}
+ CFBundleURLSchemes
+
+ {{.Scheme}}
+
+ CFBundleTypeRole
+ {{.Role}}
+
+ {{end}}
+
+ {{end}}
+
+
diff --git a/v2/examples/tray-icon/build/windows/icon.ico b/v2/examples/tray-icon/build/windows/icon.ico
new file mode 100644
index 000000000..f33479841
Binary files /dev/null and b/v2/examples/tray-icon/build/windows/icon.ico differ
diff --git a/v2/examples/tray-icon/build/windows/info.json b/v2/examples/tray-icon/build/windows/info.json
new file mode 100644
index 000000000..9727946b7
--- /dev/null
+++ b/v2/examples/tray-icon/build/windows/info.json
@@ -0,0 +1,15 @@
+{
+ "fixed": {
+ "file_version": "{{.Info.ProductVersion}}"
+ },
+ "info": {
+ "0000": {
+ "ProductVersion": "{{.Info.ProductVersion}}",
+ "CompanyName": "{{.Info.CompanyName}}",
+ "FileDescription": "{{.Info.ProductName}}",
+ "LegalCopyright": "{{.Info.Copyright}}",
+ "ProductName": "{{.Info.ProductName}}",
+ "Comments": "{{.Info.Comments}}"
+ }
+ }
+}
\ No newline at end of file
diff --git a/v2/examples/tray-icon/build/windows/wails.exe.manifest b/v2/examples/tray-icon/build/windows/wails.exe.manifest
new file mode 100644
index 000000000..17e1a2387
--- /dev/null
+++ b/v2/examples/tray-icon/build/windows/wails.exe.manifest
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+ true/pm
+ permonitorv2,permonitor
+
+
+
\ No newline at end of file
diff --git a/v2/examples/tray-icon/go.mod b/v2/examples/tray-icon/go.mod
new file mode 100644
index 000000000..ef1e76bee
--- /dev/null
+++ b/v2/examples/tray-icon/go.mod
@@ -0,0 +1,37 @@
+module tray-icon
+
+go 1.23
+
+require github.com/wailsapp/wails/v2 v2.11.0
+
+require (
+ github.com/bep/debounce v1.2.1 // indirect
+ github.com/go-ole/go-ole v1.3.0 // indirect
+ github.com/godbus/dbus/v5 v5.1.0 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/gorilla/websocket v1.5.3 // indirect
+ github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
+ github.com/labstack/echo/v4 v4.13.3 // indirect
+ github.com/labstack/gommon v0.4.2 // indirect
+ github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
+ github.com/leaanthony/gosod v1.0.4 // indirect
+ github.com/leaanthony/slicer v1.6.0 // indirect
+ github.com/leaanthony/u v1.1.1 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
+ github.com/pkg/errors v0.9.1 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/samber/lo v1.49.1 // indirect
+ github.com/tkrajina/go-reflector v0.5.8 // indirect
+ github.com/valyala/bytebufferpool v1.0.0 // indirect
+ github.com/valyala/fasttemplate v1.2.2 // indirect
+ github.com/wailsapp/go-webview2 v1.0.22 // indirect
+ github.com/wailsapp/mimetype v1.4.1 // indirect
+ golang.org/x/crypto v0.33.0 // indirect
+ golang.org/x/net v0.35.0 // indirect
+ golang.org/x/sys v0.30.0 // indirect
+ golang.org/x/text v0.22.0 // indirect
+)
+
+replace github.com/wailsapp/wails/v2 => ../../
diff --git a/v2/examples/tray-icon/go.sum b/v2/examples/tray-icon/go.sum
new file mode 100644
index 000000000..10d4a9b18
--- /dev/null
+++ b/v2/examples/tray-icon/go.sum
@@ -0,0 +1,79 @@
+github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
+github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+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=
+github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
+github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
+github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
+github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
+github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
+github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
+github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
+github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
+github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
+github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
+github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
+github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
+github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
+github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
+github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
+github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
+github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
+github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
+github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
+github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
+github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
+github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
+github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
+github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
+github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
+github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
+github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
+github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
+github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
+golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
+golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
+golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
+golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
+golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
+golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
+golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/v2/examples/tray-icon/main.go b/v2/examples/tray-icon/main.go
new file mode 100644
index 000000000..c1ee430e6
--- /dev/null
+++ b/v2/examples/tray-icon/main.go
@@ -0,0 +1,158 @@
+package main
+
+import (
+ "context"
+ "embed"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+
+ "github.com/wailsapp/wails/v2"
+ "github.com/wailsapp/wails/v2/pkg/menu"
+ "github.com/wailsapp/wails/v2/pkg/options"
+ "github.com/wailsapp/wails/v2/pkg/options/assetserver"
+ "github.com/wailsapp/wails/v2/pkg/runtime"
+)
+
+//go:embed all:frontend/dist
+var assets embed.FS
+
+func resolveTrayIconPath() string {
+ return resolveTrayAssetPath("tray-icon.png")
+}
+
+func resolveTrayAssetPath(filename string) string {
+ candidates := []string{
+ filepath.Join("examples", "tray-icon", "trayicons", filename),
+ filepath.Join("trayicons", filename),
+ }
+
+ for _, p := range candidates {
+ if _, err := os.Stat(p); err == nil {
+ if abs, err := filepath.Abs(p); err == nil {
+ return abs
+ }
+ return p
+ }
+ }
+
+ return candidates[0]
+}
+
+func main() {
+ app := NewApp()
+ primaryIcon := resolveTrayIconPath()
+ altIcon := resolveTrayAssetPath("tray-icon-alt.png")
+
+ state := struct {
+ mu sync.Mutex
+ mode int
+ clicks int
+ updates int
+ }{}
+
+ var buildTray func() *menu.TrayMenu
+ buildTray = func() *menu.TrayMenu {
+ state.mu.Lock()
+ mode := state.mode
+ clicks := state.clicks
+ updates := state.updates
+ state.mu.Unlock()
+
+ icon := primaryIcon
+ label := "TrayIcon-A"
+ actionText := "Primary Action: switch to B"
+ actionLog := "action A"
+ if mode == 1 {
+ icon = altIcon
+ label = "TrayIcon-B"
+ actionText = "Primary Action: switch to A"
+ actionLog = "action B"
+ }
+
+ trayMenu := menu.NewMenu()
+
+ trayMenu.AddText("Show", nil, func(_ *menu.CallbackData) {
+ runtime.Show(app.ctx)
+ })
+ trayMenu.AddText(actionText, nil, func(_ *menu.CallbackData) {
+ state.mu.Lock()
+ state.mode = 1 - state.mode
+ state.clicks++
+ state.updates++
+ currentClicks := state.clicks
+ state.mu.Unlock()
+
+ fmt.Printf("[tray-test] %s clicked (clicks=%d)\n", actionLog, currentClicks)
+ runtime.TraySetSystemTray(app.ctx, buildTray())
+ })
+
+ trayMenu.AddText("Ping", nil, func(_ *menu.CallbackData) {
+ fmt.Println("[tray-test] Ping clicked")
+ })
+
+ trayMenu.AddText("Toggle icon + text now", nil, func(_ *menu.CallbackData) {
+ state.mu.Lock()
+ state.mode = 1 - state.mode
+ state.clicks++
+ state.updates++
+ currentClicks := state.clicks
+ state.mu.Unlock()
+
+ fmt.Printf("[tray-test] manual toggle clicked (clicks=%d)\n", currentClicks)
+ runtime.TraySetSystemTray(app.ctx, buildTray())
+ })
+
+ trayMenu.AddSeparator()
+ trayMenu.AddText("Quit", nil, func(_ *menu.CallbackData) {
+ runtime.Quit(app.ctx)
+ })
+
+ return &menu.TrayMenu{
+ Label: fmt.Sprintf("%s (%d)", label, updates),
+ Tooltip: fmt.Sprintf("Mode=%s, Clicks=%d", label, clicks),
+ Image: icon,
+ Menu: trayMenu,
+ }
+ }
+
+ onStartup := func(ctx context.Context) {
+ app.startup(ctx)
+
+ go func() {
+ time.Sleep(4 * time.Second)
+ state.mu.Lock()
+ state.mode = 1 - state.mode
+ state.updates++
+ state.mu.Unlock()
+ fmt.Println("[tray-test] timed update: 4s")
+ runtime.TraySetSystemTray(app.ctx, buildTray())
+
+ time.Sleep(4 * time.Second)
+ state.mu.Lock()
+ state.mode = 1 - state.mode
+ state.updates++
+ state.mu.Unlock()
+ fmt.Println("[tray-test] timed update: 8s")
+ runtime.TraySetSystemTray(app.ctx, buildTray())
+ }()
+ }
+
+ err := wails.Run(&options.App{
+ Title: "Wails Tray Icon Test",
+ Width: 720,
+ Height: 420,
+ AssetServer: &assetserver.Options{
+ Assets: assets,
+ },
+ OnStartup: onStartup,
+ Tray: buildTray(),
+ HideWindowOnClose: true,
+ })
+
+ if err != nil {
+ fmt.Println("[tray-test] Error:", err)
+ }
+}
diff --git a/v2/examples/tray-icon/trayicons/tray-icon-alt.png b/v2/examples/tray-icon/trayicons/tray-icon-alt.png
new file mode 100644
index 000000000..351f81caa
Binary files /dev/null and b/v2/examples/tray-icon/trayicons/tray-icon-alt.png differ
diff --git a/v2/examples/tray-icon/trayicons/tray-icon-alt.svg b/v2/examples/tray-icon/trayicons/tray-icon-alt.svg
new file mode 100644
index 000000000..11b523720
--- /dev/null
+++ b/v2/examples/tray-icon/trayicons/tray-icon-alt.svg
@@ -0,0 +1,13 @@
+
diff --git a/v2/examples/tray-icon/trayicons/tray-icon.png b/v2/examples/tray-icon/trayicons/tray-icon.png
new file mode 100644
index 000000000..925b9edbf
Binary files /dev/null and b/v2/examples/tray-icon/trayicons/tray-icon.png differ
diff --git a/v2/examples/tray-icon/trayicons/tray-icon.svg b/v2/examples/tray-icon/trayicons/tray-icon.svg
new file mode 100644
index 000000000..cd7c7e523
--- /dev/null
+++ b/v2/examples/tray-icon/trayicons/tray-icon.svg
@@ -0,0 +1,12 @@
+
diff --git a/v2/examples/tray-icon/wails.json b/v2/examples/tray-icon/wails.json
new file mode 100644
index 000000000..eaf05bcba
--- /dev/null
+++ b/v2/examples/tray-icon/wails.json
@@ -0,0 +1,5 @@
+{
+ "$schema": "https://wails.io/schemas/config.v2.json",
+ "name": "tray-icon",
+ "outputfilename": "tray-icon"
+}
diff --git a/v2/internal/app/app_dev.go b/v2/internal/app/app_dev.go
index 6de845f96..0db30b297 100644
--- a/v2/internal/app/app_dev.go
+++ b/v2/internal/app/app_dev.go
@@ -30,6 +30,9 @@ import (
func (a *App) Run() error {
err := a.frontend.Run(a.ctx)
+ if a.options.Tray != nil {
+ a.frontend.TraySetSystemTray(a.options.Tray)
+ }
a.frontend.RunMainLoop()
a.frontend.WindowClose()
if a.shutdownCallback != nil {
diff --git a/v2/internal/app/app_devtools.go b/v2/internal/app/app_devtools.go
index 60b221094..a84e8c283 100644
--- a/v2/internal/app/app_devtools.go
+++ b/v2/internal/app/app_devtools.go
@@ -1,8 +1,8 @@
-//go:build devtools
-
-package app
-
-// Note: devtools flag is also added in debug builds
-func IsDevtoolsEnabled() bool {
- return true
-}
+//go:build devtools
+
+package app
+
+// Note: devtools flag is also added in debug builds
+func IsDevtoolsEnabled() bool {
+ return true
+}
diff --git a/v2/internal/app/app_production.go b/v2/internal/app/app_production.go
index 9eb0e5a66..849374a38 100644
--- a/v2/internal/app/app_production.go
+++ b/v2/internal/app/app_production.go
@@ -16,6 +16,9 @@ import (
func (a *App) Run() error {
err := a.frontend.Run(a.ctx)
+ if a.options.Tray != nil {
+ a.frontend.TraySetSystemTray(a.options.Tray)
+ }
a.frontend.RunMainLoop()
a.frontend.WindowClose()
if a.shutdownCallback != nil {
diff --git a/v2/internal/binding/binding_test/binding_enum_ordering_test.go b/v2/internal/binding/binding_test/binding_enum_ordering_test.go
index 0939535ec..349009e31 100644
--- a/v2/internal/binding/binding_test/binding_enum_ordering_test.go
+++ b/v2/internal/binding/binding_test/binding_enum_ordering_test.go
@@ -52,10 +52,10 @@ var AllMMiddleEnumValues = []struct {
}
type EntityWithMultipleEnums struct {
- Name string `json:"name"`
- EnumZ ZFirstEnum `json:"enumZ"`
- EnumA ASecondEnum `json:"enumA"`
- EnumM MMiddleEnum `json:"enumM"`
+ Name string `json:"name"`
+ EnumZ ZFirstEnum `json:"enumZ"`
+ EnumA ASecondEnum `json:"enumA"`
+ EnumM MMiddleEnum `json:"enumM"`
}
func (e EntityWithMultipleEnums) Get() EntityWithMultipleEnums {
diff --git a/v2/internal/frontend/desktop/darwin/Application.h b/v2/internal/frontend/desktop/darwin/Application.h
index 4d8bbd37b..b805ac426 100644
--- a/v2/internal/frontend/desktop/darwin/Application.h
+++ b/v2/internal/frontend/desktop/darwin/Application.h
@@ -62,6 +62,8 @@ void AppendRole(void *inctx, void *inMenu, int role);
void SetAsApplicationMenu(void *inctx, void *inMenu);
void UpdateApplicationMenu(void *inctx);
+void TraySetSystemTray(void *inctx, const char* label, const char* image, int isTemplate, const char* tooltip, void *inMenu);
+
void SetAbout(void *inctx, const char* title, const char* description, void* imagedata, int datalen);
void* AppendMenuItem(void* inctx, void* nsmenu, const char* label, const char* shortcutKey, int modifiers, int disabled, int checked, int menuItemID);
void AppendSeparator(void* inMenu);
diff --git a/v2/internal/frontend/desktop/darwin/Application.m b/v2/internal/frontend/desktop/darwin/Application.m
index 38d349c2c..7fa83f487 100644
--- a/v2/internal/frontend/desktop/darwin/Application.m
+++ b/v2/internal/frontend/desktop/darwin/Application.m
@@ -336,6 +336,52 @@ void UpdateApplicationMenu(void *inctx) {
)
}
+void TraySetSystemTray(void *inctx, const char* label, const char* image, int isTemplate, const char* tooltip, void *inMenu) {
+ WailsContext *ctx = (__bridge WailsContext*) inctx;
+ WailsMenu *menu = (__bridge WailsMenu*) inMenu;
+ NSString *nslabel = safeInit(label);
+ NSString *nsimage = safeInit(image);
+ NSString *nstooltip = safeInit(tooltip);
+
+ ON_MAIN_THREAD(
+ if (ctx.trayItem == nil) {
+ ctx.trayItem = [[NSStatusBar systemStatusBar] statusItemWithLength:NSVariableStatusItemLength];
+ }
+ if (nslabel != nil) {
+ ctx.trayItem.button.title = nslabel;
+ }
+ if (nstooltip != nil) {
+ ctx.trayItem.button.toolTip = nstooltip;
+ }
+ if (nsimage != nil) {
+ if ([nsimage length] > 0) {
+ NSImage *icon = [[NSImage alloc] initWithContentsOfFile:nsimage];
+ if (icon == nil) {
+ // Try as base64
+ NSData *data = [[NSData alloc] initWithBase64EncodedString:nsimage options:NSDataBase64DecodingIgnoreUnknownCharacters];
+ if (data != nil) {
+ icon = [[NSImage alloc] initWithData:data];
+ [data release];
+ }
+ }
+ if (icon != nil) {
+ if (isTemplate) {
+ [icon setTemplate:YES];
+ }
+ ctx.trayItem.button.image = icon;
+ ctx.trayItem.button.imagePosition = NSImageLeft;
+ [icon release];
+ }
+ } else {
+ ctx.trayItem.button.image = nil;
+ }
+ }
+ if (menu != nil) {
+ [ctx.trayItem setMenu:menu];
+ }
+ );
+}
+
void SetAbout(void *inctx, const char* title, const char* description, void* imagedata, int datalen) {
WailsContext *ctx = (__bridge WailsContext*) inctx;
NSString *_title = safeInit(title);
diff --git a/v2/internal/frontend/desktop/darwin/WailsContext.h b/v2/internal/frontend/desktop/darwin/WailsContext.h
index 2ec6d8707..6e9823b7e 100644
--- a/v2/internal/frontend/desktop/darwin/WailsContext.h
+++ b/v2/internal/frontend/desktop/darwin/WailsContext.h
@@ -36,7 +36,7 @@
@property (retain) WailsWebView* webview;
@property (nonatomic, assign) id appdelegate;
-@property bool hideOnClose;
+@property int hideOnClose;
@property bool shuttingDown;
@property bool startHidden;
@property bool startFullscreen;
@@ -55,6 +55,8 @@
@property (retain) NSMenu* applicationMenu;
+@property (retain) NSStatusItem* trayItem;
+
@property (retain) NSImage* aboutImage;
@property (retain) NSString* aboutTitle;
@property (retain) NSString* aboutDescription;
@@ -65,7 +67,7 @@ struct Preferences {
bool *fullscreenEnabled;
};
-- (void) CreateWindow:(int)width :(int)height :(bool)frameless :(bool)resizable :(bool)zoomable :(bool)fullscreen :(bool)fullSizeContent :(bool)hideTitleBar :(bool)titlebarAppearsTransparent :(bool)hideTitle :(bool)useToolbar :(bool)hideToolbarSeparator :(bool)webviewIsTransparent :(bool)hideWindowOnClose :(NSString *)appearance :(bool)windowIsTranslucent :(int)minWidth :(int)minHeight :(int)maxWidth :(int)maxHeight :(bool)fraudulentWebsiteWarningEnabled :(struct Preferences)preferences :(bool)enableDragAndDrop :(bool)disableWebViewDragAndDrop;
+- (void) CreateWindow:(int)width :(int)height :(bool)frameless :(bool)resizable :(bool)zoomable :(bool)fullscreen :(bool)fullSizeContent :(bool)hideTitleBar :(bool)titlebarAppearsTransparent :(bool)hideTitle :(bool)useToolbar :(bool)hideToolbarSeparator :(bool)webviewIsTransparent :(int)hideWindowOnClose :(NSString *)appearance :(bool)windowIsTranslucent :(int)minWidth :(int)minHeight :(int)maxWidth :(int)maxHeight :(bool)fraudulentWebsiteWarningEnabled :(struct Preferences)preferences :(bool)enableDragAndDrop :(bool)disableWebViewDragAndDrop;
- (void) SetSize:(int)width :(int)height;
- (void) SetPosition:(int)x :(int) y;
- (void) SetMinSize:(int)minWidth :(int)minHeight;
diff --git a/v2/internal/frontend/desktop/darwin/WailsContext.m b/v2/internal/frontend/desktop/darwin/WailsContext.m
index 7c9660d54..7afffb2ee 100644
--- a/v2/internal/frontend/desktop/darwin/WailsContext.m
+++ b/v2/internal/frontend/desktop/darwin/WailsContext.m
@@ -109,6 +109,10 @@ typedef void (^schemeTaskCaller)(id);
[self.mouseEvent release];
[self.userContentController release];
[self.applicationMenu release];
+ if (self.trayItem != nil) {
+ [[NSStatusBar systemStatusBar] removeStatusItem:self.trayItem];
+ [self.trayItem release];
+ }
[super dealloc];
}
@@ -136,7 +140,7 @@ typedef void (^schemeTaskCaller)(id);
return NO;
}
-- (void) CreateWindow:(int)width :(int)height :(bool)frameless :(bool)resizable :(bool)zoomable :(bool)fullscreen :(bool)fullSizeContent :(bool)hideTitleBar :(bool)titlebarAppearsTransparent :(bool)hideTitle :(bool)useToolbar :(bool)hideToolbarSeparator :(bool)webviewIsTransparent :(bool)hideWindowOnClose :(NSString*)appearance :(bool)windowIsTranslucent :(int)minWidth :(int)minHeight :(int)maxWidth :(int)maxHeight :(bool)fraudulentWebsiteWarningEnabled :(struct Preferences)preferences :(bool)enableDragAndDrop :(bool)disableWebViewDragAndDrop {
+- (void) CreateWindow:(int)width :(int)height :(bool)frameless :(bool)resizable :(bool)zoomable :(bool)fullscreen :(bool)fullSizeContent :(bool)hideTitleBar :(bool)titlebarAppearsTransparent :(bool)hideTitle :(bool)useToolbar :(bool)hideToolbarSeparator :(bool)webviewIsTransparent :(int)hideWindowOnClose :(NSString*)appearance :(bool)windowIsTranslucent :(int)minWidth :(int)minHeight :(int)maxWidth :(int)maxHeight :(bool)fraudulentWebsiteWarningEnabled :(struct Preferences)preferences :(bool)enableDragAndDrop :(bool)disableWebViewDragAndDrop {
NSWindowStyleMask styleMask = 0;
if( !frameless ) {
@@ -397,6 +401,9 @@ typedef void (^schemeTaskCaller)(id);
}
- (void) Show {
+ if ([NSApp activationPolicy] == NSApplicationActivationPolicyAccessory) {
+ [NSApp setActivationPolicy:NSApplicationActivationPolicyRegular];
+ }
[self.mainWindow makeKeyAndOrderFront:nil];
[NSApp activateIgnoringOtherApps:YES];
}
diff --git a/v2/internal/frontend/desktop/darwin/WindowDelegate.h b/v2/internal/frontend/desktop/darwin/WindowDelegate.h
index 6f83e0e48..08307e1ba 100644
--- a/v2/internal/frontend/desktop/darwin/WindowDelegate.h
+++ b/v2/internal/frontend/desktop/darwin/WindowDelegate.h
@@ -12,7 +12,7 @@
@interface WindowDelegate : NSObject
-@property bool hideOnClose;
+@property int hideOnClose;
@property (assign) WailsContext* ctx;
diff --git a/v2/internal/frontend/desktop/darwin/WindowDelegate.m b/v2/internal/frontend/desktop/darwin/WindowDelegate.m
index 915f12853..be2e9fb0e 100644
--- a/v2/internal/frontend/desktop/darwin/WindowDelegate.m
+++ b/v2/internal/frontend/desktop/darwin/WindowDelegate.m
@@ -14,10 +14,15 @@
@implementation WindowDelegate
- (BOOL)windowShouldClose:(WailsWindow *)sender {
- if( self.hideOnClose ) {
+ if( self.hideOnClose == 1 ) {
[NSApp hide:nil];
return false;
}
+ if( self.hideOnClose == 2 ) {
+ [sender orderOut:nil];
+ [NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory];
+ return false;
+ }
processMessage("Q");
return false;
}
diff --git a/v2/internal/frontend/desktop/darwin/frontend.go b/v2/internal/frontend/desktop/darwin/frontend.go
index 6566445d5..027a199d4 100644
--- a/v2/internal/frontend/desktop/darwin/frontend.go
+++ b/v2/internal/frontend/desktop/darwin/frontend.go
@@ -28,6 +28,7 @@ import (
"github.com/wailsapp/wails/v2/pkg/assetserver"
"github.com/wailsapp/wails/v2/pkg/assetserver/webview"
+ "github.com/wailsapp/wails/v2/pkg/menu"
"github.com/wailsapp/wails/v2/internal/binding"
"github.com/wailsapp/wails/v2/internal/frontend"
@@ -76,6 +77,8 @@ type Frontend struct {
bindings *binding.Bindings
dispatcher frontend.Dispatcher
+ trayMenu *menu.TrayMenu
+
originValidator *originvalidator.OriginValidator
}
diff --git a/v2/internal/frontend/desktop/darwin/menu.go b/v2/internal/frontend/desktop/darwin/menu.go
index 24dbe3201..b701d71a4 100644
--- a/v2/internal/frontend/desktop/darwin/menu.go
+++ b/v2/internal/frontend/desktop/darwin/menu.go
@@ -132,3 +132,12 @@ func (f *Frontend) MenuSetApplicationMenu(menu *menu.Menu) {
func (f *Frontend) MenuUpdateApplicationMenu() {
f.mainWindow.UpdateApplicationMenu()
}
+
+func (f *Frontend) TraySetSystemTray(trayMenu *menu.TrayMenu) {
+ if trayMenu != nil {
+ f.trayMenu = trayMenu
+ }
+ if f.mainWindow != nil && f.trayMenu != nil {
+ f.mainWindow.TraySetSystemTray(f.trayMenu)
+ }
+}
diff --git a/v2/internal/frontend/desktop/darwin/window.go b/v2/internal/frontend/desktop/darwin/window.go
index 87d4213d9..314c45e0c 100644
--- a/v2/internal/frontend/desktop/darwin/window.go
+++ b/v2/internal/frontend/desktop/darwin/window.go
@@ -56,7 +56,6 @@ func NewWindow(frontendOptions *options.App, debug bool, devtools bool) *Window
resizable := bool2Cint(!frontendOptions.DisableResize)
fullscreen := bool2Cint(frontendOptions.Fullscreen)
alwaysOnTop := bool2Cint(frontendOptions.AlwaysOnTop)
- hideWindowOnClose := bool2Cint(frontendOptions.HideWindowOnClose)
startsHidden := bool2Cint(frontendOptions.StartHidden)
devtoolsEnabled := bool2Cint(devtools)
defaultContextMenuEnabled := bool2Cint(debug || frontendOptions.EnableDefaultContextMenu)
@@ -121,9 +120,18 @@ func NewWindow(frontendOptions *options.App, debug bool, devtools bool) *Window
appearance = c.String(string(mac.Appearance))
}
+
+ hideWindowOnClose := 0
+ if frontendOptions.HideWindowOnClose {
+ hideWindowOnClose = 1
+ if frontendOptions.Tray != nil {
+ hideWindowOnClose = 2
+ }
+ }
+
var context *C.WailsContext = C.Create(title, width, height, frameless, resizable, zoomable, fullscreen, fullSizeContent,
hideTitleBar, titlebarAppearsTransparent, hideTitle, useToolbar, hideToolbarSeparator, webviewIsTransparent,
- alwaysOnTop, hideWindowOnClose, appearance, windowIsTranslucent, contentProtection, devtoolsEnabled, defaultContextMenuEnabled,
+ alwaysOnTop, C.int(hideWindowOnClose), appearance, windowIsTranslucent, contentProtection, devtoolsEnabled, defaultContextMenuEnabled,
windowStartState, startsHidden, minWidth, minHeight, maxWidth, maxHeight, enableFraudulentWebsiteWarnings,
preferences, singleInstanceEnabled, singleInstanceUniqueId, enableDragAndDrop, disableWebViewDragAndDrop,
)
@@ -308,6 +316,37 @@ func (w *Window) UpdateApplicationMenu() {
C.UpdateApplicationMenu(w.context)
}
+func (w *Window) TraySetSystemTray(trayMenu *menu.TrayMenu) {
+ if w == nil || w.context == nil {
+ return
+ }
+ if trayMenu == nil {
+ return
+ }
+ label := C.CString(trayMenu.Label)
+ defer C.free(unsafe.Pointer(label))
+
+ image := C.CString(trayMenu.Image)
+ defer C.free(unsafe.Pointer(image))
+
+ tooltip := C.CString(trayMenu.Tooltip)
+ defer C.free(unsafe.Pointer(tooltip))
+
+ var nsmenu unsafe.Pointer
+ if trayMenu.Menu != nil {
+ appMenu := NewNSMenu(w.context, "")
+ processMenu(appMenu, trayMenu.Menu)
+ nsmenu = appMenu.nsmenu
+ }
+
+ isTemplate := 0
+ if trayMenu.MacTemplateImage {
+ isTemplate = 1
+ }
+
+ C.TraySetSystemTray(w.context, label, image, C.int(isTemplate), tooltip, nsmenu)
+}
+
func (w Window) Print() {
C.WindowPrint(w.context)
}
diff --git a/v2/internal/frontend/desktop/linux/gtk.go b/v2/internal/frontend/desktop/linux/gtk.go
index 67a38c7a0..689313e86 100644
--- a/v2/internal/frontend/desktop/linux/gtk.go
+++ b/v2/internal/frontend/desktop/linux/gtk.go
@@ -22,6 +22,8 @@ import (
"github.com/wailsapp/wails/v2/pkg/menu"
)
+var radioUpdating bool
+
func GtkMenuItemWithLabel(label string) *C.GtkWidget {
cLabel := C.CString(label)
result := C.gtk_menu_item_new_with_label(cLabel)
@@ -45,41 +47,64 @@ func GtkRadioMenuItemWithLabel(label string, group *C.GSList) *C.GtkWidget {
//export handleMenuItemClick
func handleMenuItemClick(gtkWidget unsafe.Pointer) {
- // Make sure to execute the final callback on a new goroutine otherwise if the callback e.g. tries to open a dialog, the
- // main thread will get blocked and so the message loop blocks. As a result the app will block and shows a
- // "not responding" dialog.
+ invokeOnMainThread(func() {
+ // Make sure to execute the final callback on a new goroutine otherwise if the callback e.g. tries to open a dialog, the
+ // main thread will get blocked and so the message loop blocks. As a result the app will block and shows a
+ // "not responding" dialog.
- item := gtkSignalToMenuItem[(*C.GtkWidget)(gtkWidget)]
- switch item.Type {
- case menu.CheckboxType:
- item.Checked = !item.Checked
- checked := C.int(0)
- if item.Checked {
- checked = C.int(1)
+ widget := (*C.GtkWidget)(gtkWidget)
+ item := appMenuCache.gtkSignalToMenuItem[widget]
+ if item == nil {
+ item = trayMenuCache.gtkSignalToMenuItem[widget]
}
- for _, gtkCheckbox := range gtkCheckboxCache[item] {
- handler := gtkSignalHandlers[gtkCheckbox]
- C.blockClick(gtkCheckbox, handler)
- C.gtk_check_menu_item_set_active(C.toGtkCheckMenuItem(unsafe.Pointer(gtkCheckbox)), checked)
- C.unblockClick(gtkCheckbox, handler)
+ if item == nil {
+ return
}
- go item.Click(&menu.CallbackData{MenuItem: item})
- case menu.RadioType:
- gtkRadioItems := gtkRadioMenuCache[item]
- active := C.gtk_check_menu_item_get_active(C.toGtkCheckMenuItem(gtkWidget))
- if int(active) == 1 {
- for _, gtkRadioItem := range gtkRadioItems {
- handler := gtkSignalHandlers[gtkRadioItem]
- C.blockClick(gtkRadioItem, handler)
- C.gtk_check_menu_item_set_active(C.toGtkCheckMenuItem(unsafe.Pointer(gtkRadioItem)), 1)
- C.unblockClick(gtkRadioItem, handler)
+
+ switch item.Type {
+ case menu.CheckboxType:
+ item.Checked = !item.Checked
+ checked := C.int(0)
+ if item.Checked {
+ checked = C.int(1)
}
- item.Checked = true
+
+ updateCheckbox := func(cache *menuCache) {
+ for _, gtkCheckbox := range cache.gtkCheckboxCache[item] {
+ handler := cache.gtkSignalHandlers[gtkCheckbox]
+ C.blockClick(gtkCheckbox, handler)
+ C.gtk_check_menu_item_set_active(C.toGtkCheckMenuItem(unsafe.Pointer(gtkCheckbox)), checked)
+ C.unblockClick(gtkCheckbox, handler)
+ }
+ }
+ updateCheckbox(appMenuCache)
+ updateCheckbox(trayMenuCache)
+
+ go item.Click(&menu.CallbackData{MenuItem: item})
+ case menu.RadioType:
+ active := C.gtk_check_menu_item_get_active(C.toGtkCheckMenuItem(gtkWidget))
+ if int(active) == 1 {
+ updateRadio := func(cache *menuCache) {
+ for _, gtkRadioItem := range cache.gtkRadioMenuCache[item] {
+ handler := cache.gtkSignalHandlers[gtkRadioItem]
+ C.blockClick(gtkRadioItem, handler)
+ C.gtk_check_menu_item_set_active(C.toGtkCheckMenuItem(unsafe.Pointer(gtkRadioItem)), 1)
+ C.unblockClick(gtkRadioItem, handler)
+ }
+ }
+ radioUpdating = true
+ updateRadio(appMenuCache)
+ updateRadio(trayMenuCache)
+ radioUpdating = false
+ item.Checked = true
+ go item.Click(&menu.CallbackData{MenuItem: item})
+ } else {
+ if !radioUpdating {
+ item.Checked = false
+ }
+ }
+ default:
go item.Click(&menu.CallbackData{MenuItem: item})
- } else {
- item.Checked = false
}
- default:
- go item.Click(&menu.CallbackData{MenuItem: item})
- }
+ })
}
diff --git a/v2/internal/frontend/desktop/linux/menu.go b/v2/internal/frontend/desktop/linux/menu.go
index a61d190bd..4e47d6596 100644
--- a/v2/internal/frontend/desktop/linux/menu.go
+++ b/v2/internal/frontend/desktop/linux/menu.go
@@ -9,8 +9,11 @@ package linux
#cgo webkit2_41 pkg-config: webkit2gtk-4.1
#include "gtk/gtk.h"
+#include "window.h"
static GtkMenuItem *toGtkMenuItem(void *pointer) { return (GTK_MENU_ITEM(pointer)); }
+static GtkMenu *toGtkMenu(void *pointer) { return (GTK_MENU(pointer)); }
+static GtkWindow *toGtkWindow(void *pointer) { return (GTK_WINDOW(pointer)); }
static GtkMenuShell *toGtkMenuShell(void *pointer) { return (GTK_MENU_SHELL(pointer)); }
static GtkCheckMenuItem *toGtkCheckMenuItem(void *pointer) { return (GTK_CHECK_MENU_ITEM(pointer)); }
static GtkRadioMenuItem *toGtkRadioMenuItem(void *pointer) { return (GTK_RADIO_MENU_ITEM(pointer)); }
@@ -35,19 +38,39 @@ void addAccelerator(GtkWidget* menuItem, GtkAccelGroup* group, guint key, GdkMod
*/
import "C"
import (
+ "encoding/base64"
+ "os"
+ "runtime"
"unsafe"
"github.com/wailsapp/wails/v2/pkg/menu"
)
-var menuIdCounter int
-var menuItemToId map[*menu.MenuItem]int
-var menuIdToItem map[int]*menu.MenuItem
-var gtkCheckboxCache map[*menu.MenuItem][]*C.GtkWidget
-var gtkMenuCache map[*menu.MenuItem]*C.GtkWidget
-var gtkRadioMenuCache map[*menu.MenuItem][]*C.GtkWidget
-var gtkSignalHandlers map[*C.GtkWidget]C.gulong
-var gtkSignalToMenuItem map[*C.GtkWidget]*menu.MenuItem
+type menuCache struct {
+ menuIdCounter int
+ menuItemToId map[*menu.MenuItem]int
+ menuIdToItem map[int]*menu.MenuItem
+ gtkCheckboxCache map[*menu.MenuItem][]*C.GtkWidget
+ gtkMenuCache map[*menu.MenuItem]*C.GtkWidget
+ gtkRadioMenuCache map[*menu.MenuItem][]*C.GtkWidget
+ gtkSignalHandlers map[*C.GtkWidget]C.gulong
+ gtkSignalToMenuItem map[*C.GtkWidget]*menu.MenuItem
+}
+
+func newMenuCache() *menuCache {
+ return &menuCache{
+ menuItemToId: make(map[*menu.MenuItem]int),
+ menuIdToItem: make(map[int]*menu.MenuItem),
+ gtkCheckboxCache: make(map[*menu.MenuItem][]*C.GtkWidget),
+ gtkMenuCache: make(map[*menu.MenuItem]*C.GtkWidget),
+ gtkRadioMenuCache: make(map[*menu.MenuItem][]*C.GtkWidget),
+ gtkSignalHandlers: make(map[*C.GtkWidget]C.gulong),
+ gtkSignalToMenuItem: make(map[*C.GtkWidget]*menu.MenuItem),
+ }
+}
+
+var appMenuCache = newMenuCache()
+var trayMenuCache = newMenuCache()
func (f *Frontend) MenuSetApplicationMenu(menu *menu.Menu) {
f.mainWindow.SetApplicationMenu(menu)
@@ -57,6 +80,71 @@ func (f *Frontend) MenuUpdateApplicationMenu() {
f.mainWindow.SetApplicationMenu(f.mainWindow.applicationMenu)
}
+func (f *Frontend) TraySetSystemTray(trayMenu *menu.TrayMenu) {
+ if trayMenu == nil {
+ return
+ }
+
+ invokeOnMainThread(func() {
+ trayMenuCache = newMenuCache()
+ var label *C.char
+ if trayMenu.Label != "" {
+ label = C.CString(trayMenu.Label)
+ defer C.free(unsafe.Pointer(label))
+ }
+
+ var tooltip *C.char
+ if trayMenu.Tooltip != "" {
+ tooltip = C.CString(trayMenu.Tooltip)
+ defer C.free(unsafe.Pointer(tooltip))
+ }
+
+ var imageData *C.guchar
+ var imageLen C.gsize
+ var imageBytes []byte
+ if trayMenu.Image != "" {
+ // Try file
+ if _, err := os.Stat(trayMenu.Image); err == nil {
+ data, err := os.ReadFile(trayMenu.Image)
+ if err == nil && len(data) > 0 {
+ imageBytes = data
+ }
+ } else {
+ // Try base64
+ data, err := base64.StdEncoding.DecodeString(trayMenu.Image)
+ if err == nil && len(data) > 0 {
+ imageBytes = data
+ }
+ }
+ if len(imageBytes) > 0 {
+ imageData = (*C.guchar)(unsafe.Pointer(&imageBytes[0]))
+ imageLen = C.gsize(len(imageBytes))
+ }
+ }
+
+ if f.mainWindow.trayAccelGroup != nil {
+ C.gtk_window_remove_accel_group(C.toGtkWindow(f.mainWindow.gtkWindow), f.mainWindow.trayAccelGroup)
+ C.g_object_unref(C.gpointer(f.mainWindow.trayAccelGroup))
+ f.mainWindow.trayAccelGroup = nil
+ }
+
+ var gtkMenu *C.GtkWidget
+ if trayMenu.Menu != nil {
+ gtkMenu = C.gtk_menu_new()
+ f.mainWindow.trayAccelGroup = C.gtk_accel_group_new()
+ C.gtk_window_add_accel_group(C.toGtkWindow(f.mainWindow.gtkWindow), f.mainWindow.trayAccelGroup)
+ C.gtk_menu_set_accel_group(C.toGtkMenu(unsafe.Pointer(gtkMenu)), f.mainWindow.trayAccelGroup)
+ currentRadioGroup = nil
+ for _, item := range trayMenu.Menu.Items {
+ processMenuItem(gtkMenu, item, f.mainWindow.trayAccelGroup, trayMenuCache)
+ }
+ }
+
+ C.TraySetSystemTray(C.toGtkWindow(f.mainWindow.gtkWindow), label, imageData, imageLen, tooltip, gtkMenu)
+ runtime.KeepAlive(imageBytes)
+ })
+}
+
func (w *Window) SetApplicationMenu(inmenu *menu.Menu) {
if inmenu == nil {
return
@@ -66,13 +154,7 @@ func (w *Window) SetApplicationMenu(inmenu *menu.Menu) {
w.accels = C.gtk_accel_group_new()
C.gtk_window_add_accel_group(w.asGTKWindow(), w.accels)
- menuItemToId = make(map[*menu.MenuItem]int)
- menuIdToItem = make(map[int]*menu.MenuItem)
- gtkCheckboxCache = make(map[*menu.MenuItem][]*C.GtkWidget)
- gtkMenuCache = make(map[*menu.MenuItem]*C.GtkWidget)
- gtkRadioMenuCache = make(map[*menu.MenuItem][]*C.GtkWidget)
- gtkSignalHandlers = make(map[*C.GtkWidget]C.gulong)
- gtkSignalToMenuItem = make(map[*C.GtkWidget]*menu.MenuItem)
+ appMenuCache = newMenuCache()
// Increase ref count?
w.menubar = C.gtk_menu_bar_new()
@@ -83,36 +165,37 @@ func (w *Window) SetApplicationMenu(inmenu *menu.Menu) {
}
func processMenu(window *Window, menu *menu.Menu) {
+ currentRadioGroup = nil
for _, menuItem := range menu.Items {
if menuItem.SubMenu != nil {
- submenu := processSubmenu(menuItem, window.accels)
+ submenu := processSubmenu(menuItem, window.accels, appMenuCache)
C.gtk_menu_shell_append(C.toGtkMenuShell(unsafe.Pointer(window.menubar)), submenu)
}
}
}
-func processSubmenu(menuItem *menu.MenuItem, group *C.GtkAccelGroup) *C.GtkWidget {
- existingMenu := gtkMenuCache[menuItem]
+func processSubmenu(menuItem *menu.MenuItem, group *C.GtkAccelGroup, cache *menuCache) *C.GtkWidget {
+ existingMenu := cache.gtkMenuCache[menuItem]
if existingMenu != nil {
return existingMenu
}
gtkMenu := C.gtk_menu_new()
submenu := GtkMenuItemWithLabel(menuItem.Label)
for _, menuItem := range menuItem.SubMenu.Items {
- menuID := menuIdCounter
- menuIdToItem[menuID] = menuItem
- menuItemToId[menuItem] = menuID
- menuIdCounter++
- processMenuItem(gtkMenu, menuItem, group)
+ menuID := cache.menuIdCounter
+ cache.menuIdToItem[menuID] = menuItem
+ cache.menuItemToId[menuItem] = menuID
+ cache.menuIdCounter++
+ processMenuItem(gtkMenu, menuItem, group, cache)
}
C.gtk_menu_item_set_submenu(C.toGtkMenuItem(unsafe.Pointer(submenu)), gtkMenu)
- gtkMenuCache[menuItem] = existingMenu
+ cache.gtkMenuCache[menuItem] = gtkMenu
return submenu
}
var currentRadioGroup *C.GSList
-func processMenuItem(parent *C.GtkWidget, menuItem *menu.MenuItem, group *C.GtkAccelGroup) {
+func processMenuItem(parent *C.GtkWidget, menuItem *menu.MenuItem, group *C.GtkAccelGroup, cache *menuCache) {
if menuItem.Hidden {
return
}
@@ -137,7 +220,7 @@ func processMenuItem(parent *C.GtkWidget, menuItem *menu.MenuItem, group *C.GtkA
if menuItem.Checked {
C.gtk_check_menu_item_set_active(C.toGtkCheckMenuItem(unsafe.Pointer(result)), 1)
}
- gtkCheckboxCache[menuItem] = append(gtkCheckboxCache[menuItem], result)
+ cache.gtkCheckboxCache[menuItem] = append(cache.gtkCheckboxCache[menuItem], result)
case menu.RadioType:
result = GtkRadioMenuItemWithLabel(menuItem.Label, currentRadioGroup)
@@ -145,24 +228,24 @@ func processMenuItem(parent *C.GtkWidget, menuItem *menu.MenuItem, group *C.GtkA
if menuItem.Checked {
C.gtk_check_menu_item_set_active(C.toGtkCheckMenuItem(unsafe.Pointer(result)), 1)
}
- gtkRadioMenuCache[menuItem] = append(gtkRadioMenuCache[menuItem], result)
+ cache.gtkRadioMenuCache[menuItem] = append(cache.gtkRadioMenuCache[menuItem], result)
case menu.SubmenuType:
- result = processSubmenu(menuItem, group)
+ result = processSubmenu(menuItem, group, cache)
}
C.gtk_menu_shell_append(C.toGtkMenuShell(unsafe.Pointer(parent)), result)
C.gtk_widget_show(result)
if menuItem.Click != nil {
handler := C.connectClick(result)
- gtkSignalHandlers[result] = handler
- gtkSignalToMenuItem[result] = menuItem
+ cache.gtkSignalHandlers[result] = handler
+ cache.gtkSignalToMenuItem[result] = menuItem
}
if menuItem.Disabled {
C.gtk_widget_set_sensitive(result, 0)
}
- if menuItem.Accelerator != nil {
+ if menuItem.Accelerator != nil && group != nil {
key, mods := acceleratorToGTK(menuItem.Accelerator)
C.addAccelerator(result, group, key, mods)
}
diff --git a/v2/internal/frontend/desktop/linux/window.c b/v2/internal/frontend/desktop/linux/window.c
index 5441db022..415159a3c 100644
--- a/v2/internal/frontend/desktop/linux/window.c
+++ b/v2/internal/frontend/desktop/linux/window.c
@@ -1,11 +1,13 @@
#include
#include
+#include
#include
#include
#include
#include
#include
#include
+#include
#include "window.h"
// These are the x,y,time & button of the last mouse down event
@@ -18,7 +20,11 @@ static int wmIsWayland = -1;
static int decoratorWidth = -1;
static int decoratorHeight = -1;
-// casts
+// Tray management
+static AppIndicator *indicator = NULL;
+static char *indicator_temp_icon_path = NULL;
+static GtkWidget *current_window = NULL;
+
void ExecuteOnMainThread(void *f, gpointer jscallback)
{
g_idle_add((GSourceFunc)f, (gpointer)jscallback);
@@ -153,7 +159,9 @@ void SetWindowIcon(GtkWindow *window, const guchar *buf, gsize len)
{
return;
}
- if (gdk_pixbuf_loader_write(loader, buf, len, NULL) && gdk_pixbuf_loader_close(loader, NULL))
+ gboolean write_success = gdk_pixbuf_loader_write(loader, buf, len, NULL);
+ gboolean close_success = gdk_pixbuf_loader_close(loader, NULL);
+ if (write_success && close_success)
{
GdkPixbuf *pixbuf = gdk_pixbuf_loader_get_pixbuf(loader);
if (pixbuf)
@@ -889,3 +897,99 @@ void InstallF12Hotkey(void *window)
GClosure *closure = g_cclosure_new(G_CALLBACK(sendShowInspectorMessage), window, NULL);
gtk_accel_group_connect(accel_group, GDK_KEY_F12, GDK_CONTROL_MASK | GDK_SHIFT_MASK, GTK_ACCEL_VISIBLE, closure);
}
+
+static void on_status_icon_activate(AppIndicator *indicator, gpointer user_data)
+{
+ if (current_window)
+ {
+ gtk_window_present(GTK_WINDOW(current_window));
+ }
+}
+
+void TraySetSystemTray(GtkWindow *window, const char *label, const guchar *image, gsize imageLen, const char *tooltip, GtkWidget *menu)
+{
+ current_window = GTK_WIDGET(window);
+
+ if (indicator == NULL)
+ {
+ indicator = app_indicator_new("wails-tray-indicator", "", APP_INDICATOR_CATEGORY_APPLICATION_STATUS);
+ if (indicator == NULL)
+ {
+ return;
+ }
+ app_indicator_set_status(indicator, APP_INDICATOR_STATUS_ACTIVE);
+
+ // Connect the secondary activate signal (usually middle click or scroll)
+ // to show the window.
+ g_signal_connect(indicator, "X-AYATANA-SECONDARY-ACTIVATE", G_CALLBACK(on_status_icon_activate), NULL);
+ }
+
+ if (label != NULL)
+ {
+ app_indicator_set_label(indicator, label, "");
+ }
+
+ if (image != NULL && imageLen > 0)
+ {
+ GdkPixbufLoader *loader = gdk_pixbuf_loader_new();
+ gboolean write_success = gdk_pixbuf_loader_write(loader, image, imageLen, NULL);
+ gboolean close_success = gdk_pixbuf_loader_close(loader, NULL);
+ if (write_success && close_success)
+ {
+ GdkPixbuf *pixbuf = gdk_pixbuf_loader_get_pixbuf(loader);
+ if (pixbuf)
+ {
+ char *filename = NULL;
+ int fd = g_file_open_tmp("wails-tray-XXXXXX.png", &filename, NULL);
+ if (fd != -1)
+ {
+ close(fd);
+ if (gdk_pixbuf_save(pixbuf, filename, "png", NULL, NULL))
+ {
+ char *dir = g_path_get_dirname(filename);
+ char *base = g_path_get_basename(filename);
+ char *dot = strrchr(base, '.');
+ if (dot)
+ {
+ *dot = '\0';
+ }
+ app_indicator_set_icon_theme_path(indicator, dir);
+ app_indicator_set_icon_full(indicator, base, "tray icon");
+ g_free(dir);
+ g_free(base);
+
+ if (indicator_temp_icon_path != NULL)
+ {
+ unlink(indicator_temp_icon_path);
+ g_free(indicator_temp_icon_path);
+ }
+ indicator_temp_icon_path = filename;
+ }
+ else
+ {
+ unlink(filename);
+ g_free(filename);
+ }
+ }
+ }
+ }
+ g_object_unref(loader);
+ }
+
+ if (tooltip != NULL)
+ {
+ app_indicator_set_title(indicator, tooltip);
+ }
+
+ static GtkWidget *prev_menu = NULL;
+ if (menu != NULL)
+ {
+ if (prev_menu != NULL)
+ {
+ gtk_widget_destroy(prev_menu);
+ }
+ app_indicator_set_menu(indicator, GTK_MENU(menu));
+ prev_menu = menu;
+ }
+}
+
diff --git a/v2/internal/frontend/desktop/linux/window.go b/v2/internal/frontend/desktop/linux/window.go
index 0bf5ac51d..51fd9c923 100644
--- a/v2/internal/frontend/desktop/linux/window.go
+++ b/v2/internal/frontend/desktop/linux/window.go
@@ -4,12 +4,13 @@
package linux
/*
-#cgo linux pkg-config: gtk+-3.0
+#cgo linux pkg-config: gtk+-3.0 ayatana-appindicator3-0.1
#cgo !webkit2_41 pkg-config: webkit2gtk-4.0
#cgo webkit2_41 pkg-config: webkit2gtk-4.1
#include
#include
+#include
#include
#include
#include
@@ -49,6 +50,7 @@ type Window struct {
webviewBox *C.GtkWidget
vbox *C.GtkWidget
accels *C.GtkAccelGroup
+ trayAccelGroup *C.GtkAccelGroup
minWidth, minHeight, maxWidth, maxHeight int
}
@@ -98,10 +100,18 @@ func NewWindow(appoptions *options.App, debug bool, devtoolsEnabled bool) *Windo
webviewGpuPolicy = int(linux.WebviewGpuPolicyNever)
}
+ hideWindowOnClose := 0
+ if appoptions.HideWindowOnClose {
+ hideWindowOnClose = 1
+ if appoptions.Tray != nil {
+ hideWindowOnClose = 2
+ }
+ }
+
webview := C.SetupWebview(
result.contentManager,
result.asGTKWindow(),
- bool2Cint(appoptions.HideWindowOnClose),
+ C.int(hideWindowOnClose),
C.int(webviewGpuPolicy),
bool2Cint(appoptions.DragAndDrop != nil && appoptions.DragAndDrop.DisableWebViewDrop),
bool2Cint(appoptions.DragAndDrop != nil && appoptions.DragAndDrop.EnableFileDrop),
@@ -180,6 +190,10 @@ func (w *Window) UnFullscreen() {
func (w *Window) Destroy() {
C.gtk_widget_destroy(w.asGTKWidget())
C.g_object_unref(C.gpointer(w.gtkWindow))
+ if w.trayAccelGroup != nil {
+ C.g_object_unref(C.gpointer(w.trayAccelGroup))
+ w.trayAccelGroup = nil
+ }
}
func (w *Window) Close() {
diff --git a/v2/internal/frontend/desktop/linux/window.h b/v2/internal/frontend/desktop/linux/window.h
index 04410959a..88b6e689e 100644
--- a/v2/internal/frontend/desktop/linux/window.h
+++ b/v2/internal/frontend/desktop/linux/window.h
@@ -125,4 +125,6 @@ void sendShowInspectorMessage();
void ShowInspector(void *webview);
void InstallF12Hotkey(void *window);
+void TraySetSystemTray(GtkWindow *window, const char *label, const guchar *image, gsize imageLen, const char *tooltip, GtkWidget *menu);
+
#endif /* window_h */
diff --git a/v2/internal/frontend/desktop/windows/menu.go b/v2/internal/frontend/desktop/windows/menu.go
index b71128b45..16af9eadc 100644
--- a/v2/internal/frontend/desktop/windows/menu.go
+++ b/v2/internal/frontend/desktop/windows/menu.go
@@ -130,3 +130,9 @@ func (f *Frontend) MenuSetApplicationMenu(menu *menu.Menu) {
func (f *Frontend) MenuUpdateApplicationMenu() {
processMenu(f.mainWindow, f.mainWindow.applicationMenu)
}
+
+func (f *Frontend) TraySetSystemTray(trayMenu *menu.TrayMenu) {
+ if f.mainWindow != nil {
+ f.mainWindow.TraySetSystemTray(trayMenu)
+ }
+}
diff --git a/v2/internal/frontend/desktop/windows/win32/consts.go b/v2/internal/frontend/desktop/windows/win32/consts.go
index e38ea4b92..a677f0d26 100644
--- a/v2/internal/frontend/desktop/windows/win32/consts.go
+++ b/v2/internal/frontend/desktop/windows/win32/consts.go
@@ -11,6 +11,35 @@ import (
type HRESULT int32
type HANDLE uintptr
type HMONITOR HANDLE
+type HWND HANDLE
+type HICON HANDLE
+
+type NOTIFYICONDATA struct {
+ CbSize uint32
+ HWnd HWND
+ UID uint32
+ UFlags uint32
+ UCallbackMessage uint32
+ HIcon HICON
+ SzTip [128]uint16
+ DwState uint32
+ DwStateMask uint32
+ SzInfo [256]uint16
+ UVersion uint32
+ SzInfoTitle [64]uint16
+ DwInfoFlags uint32
+ GuidItem [16]byte
+}
+
+const (
+ NIM_ADD = 0x00000000
+ NIM_MODIFY = 0x00000001
+ NIM_DELETE = 0x00000002
+
+ NIF_MESSAGE = 0x00000001
+ NIF_ICON = 0x00000002
+ NIF_TIP = 0x00000004
+)
var (
moduser32 = syscall.NewLazyDLL("user32.dll")
@@ -29,12 +58,17 @@ var (
procEmptyClipboard = moduser32.NewProc("EmptyClipboard")
procGetClipboardData = moduser32.NewProc("GetClipboardData")
procSetClipboardData = moduser32.NewProc("SetClipboardData")
+ procCreateIconFromResourceEx = moduser32.NewProc("CreateIconFromResourceEx")
)
var (
moddwmapi = syscall.NewLazyDLL("dwmapi.dll")
procDwmSetWindowAttribute = moddwmapi.NewProc("DwmSetWindowAttribute")
procDwmExtendFrameIntoClientArea = moddwmapi.NewProc("DwmExtendFrameIntoClientArea")
)
+var (
+ modshell32 = syscall.NewLazyDLL("shell32.dll")
+ procShellNotifyIcon = modshell32.NewProc("Shell_NotifyIconW")
+)
var (
modwingdi = syscall.NewLazyDLL("gdi32.dll")
procCreateSolidBrush = modwingdi.NewProc("CreateSolidBrush")
diff --git a/v2/internal/frontend/desktop/windows/win32/window.go b/v2/internal/frontend/desktop/windows/win32/window.go
index 6028789de..934d128ff 100644
--- a/v2/internal/frontend/desktop/windows/win32/window.go
+++ b/v2/internal/frontend/desktop/windows/win32/window.go
@@ -100,7 +100,27 @@ func IsVisible(hwnd uintptr) bool {
ret, _, _ := procIsWindowVisible.Call(hwnd)
return ret != 0
}
+func ShellNotifyIcon(dwMessage uintptr, lpdata *NOTIFYICONDATA) bool {
+ ret, _, _ := procShellNotifyIcon.Call(dwMessage, uintptr(unsafe.Pointer(lpdata)))
+ return ret != 0
+}
+func CreateIconFromResourceEx(presbits uintptr, dwResSize uint32, fIcon bool, dwVer uint32, cxDesired int, cyDesired int) HICON {
+ var fIconUint uintptr
+ if fIcon {
+ fIconUint = 1
+ }
+ ret, _, _ := procCreateIconFromResourceEx.Call(
+ presbits,
+ uintptr(dwResSize),
+ fIconUint,
+ uintptr(dwVer),
+ uintptr(cxDesired),
+ uintptr(cyDesired),
+ 0,
+ )
+ return HICON(ret)
+}
func IsWindowFullScreen(hwnd uintptr) bool {
wRect := GetWindowRect(hwnd)
m := MonitorFromWindow(hwnd, MONITOR_DEFAULTTOPRIMARY)
diff --git a/v2/internal/frontend/desktop/windows/winc/menu.go b/v2/internal/frontend/desktop/windows/winc/menu.go
index d1567e648..869f00ea3 100644
--- a/v2/internal/frontend/desktop/windows/winc/menu.go
+++ b/v2/internal/frontend/desktop/windows/winc/menu.go
@@ -205,6 +205,10 @@ func (mi *MenuItem) OnClick() *EventManager {
return &mi.onClick
}
+func (mi *MenuItem) Handle() w32.HMENU {
+ return mi.hMenu
+}
+
func (mi *MenuItem) AddSeparator() {
addMenuItem(mi.hSubMenu, 0, "-", Shortcut{}, nil, false)
}
diff --git a/v2/internal/frontend/desktop/windows/winc/w32/user32.go b/v2/internal/frontend/desktop/windows/winc/w32/user32.go
index 707701f5e..b69c09169 100644
--- a/v2/internal/frontend/desktop/windows/winc/w32/user32.go
+++ b/v2/internal/frontend/desktop/windows/winc/w32/user32.go
@@ -820,7 +820,7 @@ func CreatePopupMenu() HMENU {
}
func TrackPopupMenuEx(hMenu HMENU, fuFlags uint32, x, y int32, hWnd HWND, lptpm *TPMPARAMS) BOOL {
- ret, _, _ := syscall.Syscall6(trackPopupMenuEx, 6,
+ ret, _, _ := syscall.SyscallN(trackPopupMenuEx,
uintptr(hMenu),
uintptr(fuFlags),
uintptr(x),
diff --git a/v2/internal/frontend/desktop/windows/window.go b/v2/internal/frontend/desktop/windows/window.go
index b04d61814..efcb97bfe 100644
--- a/v2/internal/frontend/desktop/windows/window.go
+++ b/v2/internal/frontend/desktop/windows/window.go
@@ -11,6 +11,11 @@ import (
"github.com/wailsapp/wails/v2/internal/frontend/desktop/windows/win32"
"github.com/wailsapp/wails/v2/internal/system/operatingsystem"
+ "encoding/base64"
+ "fmt"
+ "os"
+ "syscall"
+
"github.com/wailsapp/wails/v2/internal/frontend/desktop/windows/winc"
"github.com/wailsapp/wails/v2/internal/frontend/desktop/windows/winc/w32"
"github.com/wailsapp/wails/v2/pkg/menu"
@@ -18,6 +23,10 @@ import (
winoptions "github.com/wailsapp/wails/v2/pkg/options/windows"
)
+const (
+ WM_WAILS_TRAY_MESSAGE = w32.WM_APP + 500
+)
+
type Window struct {
winc.Form
frontendOptions *options.App
@@ -39,6 +48,11 @@ type Window struct {
chromium *edge.Chromium
+ // Tray
+ trayID uint32
+ trayIcon w32.HICON
+ trayMenu *menu.TrayMenu
+
// isMinimizing indicates whether the window is currently being minimized
// 标识窗口是否处于最小化状态,用于解决最小化/恢复时的闪屏问题
// This flag is used to prevent unnecessary redraws during minimize/restore transitions for frameless windows
@@ -203,6 +217,21 @@ func (w *Window) IsVisible() bool {
func (w *Window) WndProc(msg uint32, wparam, lparam uintptr) uintptr {
switch msg {
+ case WM_WAILS_TRAY_MESSAGE:
+ switch uint32(lparam) {
+ case w32.WM_RBUTTONUP:
+ if w.trayMenu != nil && w.trayMenu.Menu != nil {
+ w.showTrayMenu()
+ }
+ case w32.WM_LBUTTONUP:
+ w32.ShowWindow(w.Handle(), w32.SW_SHOW)
+ w32.SetForegroundWindow(w.Handle())
+ }
+ return 0
+ case w32.WM_DESTROY:
+ if w.trayIcon != 0 {
+ w.deleteTrayIcon()
+ }
case win32.WM_POWERBROADCAST:
switch wparam {
case win32.PBT_APMSUSPEND:
@@ -317,6 +346,118 @@ func (w *Window) WndProc(msg uint32, wparam, lparam uintptr) uintptr {
return w.Form.WndProc(msg, wparam, lparam)
}
+func (w *Window) showTrayMenu() {
+ if w.trayMenu == nil || w.trayMenu.Menu == nil {
+ return
+ }
+
+ wincMenu := winc.NewContextMenu()
+ defer w32.DestroyMenu(w32.HMENU(wincMenu.Handle()))
+ for _, item := range w.trayMenu.Menu.Items {
+ processMenuItem(wincMenu, item)
+ }
+
+ x, y, _ := w32.GetCursorPos()
+
+ w32.SetForegroundWindow(w.Handle())
+ w32.TrackPopupMenuEx(w32.HMENU(wincMenu.Handle()), w32.TPM_LEFTALIGN|w32.TPM_RIGHTBUTTON, int32(x), int32(y), w.Handle(), nil)
+ w32.PostMessage(w.Handle(), w32.WM_NULL, 0, 0)
+}
+
+func (w *Window) deleteTrayIcon() {
+ if w.trayIcon == 0 {
+ return
+ }
+ var nid win32.NOTIFYICONDATA
+ nid.CbSize = uint32(unsafe.Sizeof(nid))
+ nid.HWnd = win32.HWND(w.Handle())
+ nid.UID = w.trayID
+
+ win32.ShellNotifyIcon(win32.NIM_DELETE, &nid)
+ w32.DestroyIcon(w.trayIcon)
+ w.trayIcon = 0
+ w.trayID = 0
+}
+
+func (w *Window) TraySetSystemTray(trayMenu *menu.TrayMenu) {
+ if trayMenu == nil {
+ return
+ }
+ w.Invoke(func() {
+ w.trayMenu = trayMenu
+
+ var nid win32.NOTIFYICONDATA
+ nid.CbSize = uint32(unsafe.Sizeof(nid))
+ nid.HWnd = win32.HWND(w.Handle())
+ nid.UID = w.trayID
+ nid.UFlags = win32.NIF_MESSAGE | win32.NIF_TIP
+ nid.UCallbackMessage = WM_WAILS_TRAY_MESSAGE
+
+ if trayMenu.Tooltip != "" {
+ u16, _ := syscall.UTF16FromString(trayMenu.Tooltip)
+ copy(nid.SzTip[:], u16)
+ }
+
+ if trayMenu.Image != "" {
+ icon, err := w.loadTrayIcon(trayMenu.Image)
+ if err == nil {
+ if w.trayIcon != 0 {
+ w32.DestroyIcon(w.trayIcon)
+ }
+ w.trayIcon = icon
+ nid.HIcon = win32.HICON(icon)
+ nid.UFlags |= win32.NIF_ICON
+ } else {
+ if w.frontendOptions.Logger != nil {
+ w.frontendOptions.Logger.Error(fmt.Sprintf("Could not load tray icon: %s", err))
+ }
+ }
+ }
+
+ cmd := win32.NIM_ADD
+ if w.trayID != 0 {
+ cmd = win32.NIM_MODIFY
+ } else {
+ w.trayID = 1
+ nid.UID = w.trayID
+ }
+
+ if !win32.ShellNotifyIcon(uintptr(cmd), &nid) {
+ if w.frontendOptions.Logger != nil {
+ w.frontendOptions.Logger.Error(fmt.Sprintf("Could not set system tray icon (cmd=%d, id=%d)", cmd, nid.UID))
+ }
+ }
+ })
+}
+
+func (w *Window) loadTrayIcon(image string) (w32.HICON, error) {
+ if _, err := os.Stat(image); err == nil {
+ ico, err := winc.NewIconFromFile(image)
+ if err != nil {
+ return 0, fmt.Errorf("failed to load icon from file %q: %w", image, err)
+ }
+ return w32.HICON(ico.Handle()), nil
+ }
+
+ data, err := base64.StdEncoding.DecodeString(image)
+ if err == nil {
+ return w.createIconFromBytes(data)
+ }
+
+ return 0, fmt.Errorf("could not load icon")
+}
+
+func (w *Window) createIconFromBytes(data []byte) (w32.HICON, error) {
+ if len(data) == 0 {
+ return 0, fmt.Errorf("empty icon data")
+ }
+ icon := win32.CreateIconFromResourceEx(uintptr(unsafe.Pointer(&data[0])), uint32(len(data)), true, 0x00030000, 0, 0)
+ if icon == 0 {
+ return 0, fmt.Errorf("could not create icon from bytes")
+ }
+ return w32.HICON(icon), nil
+}
+
func (w *Window) IsMaximised() bool {
return win32.IsWindowMaximised(w.Handle())
}
diff --git a/v2/internal/frontend/frontend.go b/v2/internal/frontend/frontend.go
index 6b2ccbcae..717b0b5aa 100644
--- a/v2/internal/frontend/frontend.go
+++ b/v2/internal/frontend/frontend.go
@@ -129,6 +129,7 @@ type Frontend interface {
// Menus
MenuSetApplicationMenu(menu *menu.Menu)
MenuUpdateApplicationMenu()
+ TraySetSystemTray(trayMenu *menu.TrayMenu)
// Events
Notify(name string, data ...interface{})
diff --git a/v2/internal/go-common-file-dialog/cfd/errors.go b/v2/internal/go-common-file-dialog/cfd/errors.go
index 4ca3300b9..37bd2a96d 100644
--- a/v2/internal/go-common-file-dialog/cfd/errors.go
+++ b/v2/internal/go-common-file-dialog/cfd/errors.go
@@ -3,7 +3,7 @@ package cfd
import "errors"
var (
- ErrCancelled = errors.New("cancelled by user")
- ErrInvalidGUID = errors.New("guid cannot be nil")
+ ErrCancelled = errors.New("cancelled by user")
+ ErrInvalidGUID = errors.New("guid cannot be nil")
ErrEmptyFilters = errors.New("must specify at least one filter")
)
diff --git a/v2/internal/menumanager/traymenu_test.go b/v2/internal/menumanager/traymenu_test.go
new file mode 100644
index 000000000..2465e4cd2
--- /dev/null
+++ b/v2/internal/menumanager/traymenu_test.go
@@ -0,0 +1,45 @@
+package menumanager
+
+import (
+ "testing"
+
+ "github.com/wailsapp/wails/v2/pkg/menu"
+)
+
+func TestNewTrayMenu(t *testing.T) {
+ tm := &menu.TrayMenu{
+ Label: "Test Label",
+ Tooltip: "Test Tooltip",
+ Disabled: false,
+ }
+
+ tray := NewTrayMenu(tm)
+
+ if tray.Label != tm.Label {
+ t.Errorf("Expected label %s, got %s", tm.Label, tray.Label)
+ }
+
+ if tray.Tooltip != tm.Tooltip {
+ t.Errorf("Expected tooltip %s, got %s", tm.Tooltip, tray.Tooltip)
+ }
+
+ if tray.Disabled != tm.Disabled {
+ t.Errorf("Expected disabled %v, got %v", tm.Disabled, tray.Disabled)
+ }
+}
+
+func TestNewTrayMenuWithANSI(t *testing.T) {
+ tm := &menu.TrayMenu{
+ Label: "\033[31mRed Label\033[0m",
+ }
+
+ tray := NewTrayMenu(tm)
+
+ if tray.Label != tm.Label {
+ t.Errorf("Expected label %s, got %s", tm.Label, tray.Label)
+ }
+
+ if len(tray.StyledLabel) == 0 {
+ t.Error("Expected StyledLabel to be populated for ANSI text")
+ }
+}
diff --git a/v2/internal/platform/win32/icon.go b/v2/internal/platform/win32/icon.go
index 916b92d44..7591e5ba1 100644
--- a/v2/internal/platform/win32/icon.go
+++ b/v2/internal/platform/win32/icon.go
@@ -6,7 +6,7 @@ import (
"unsafe"
)
-func CreateIconFromResourceEx(presbits uintptr, dwResSize uint32, isIcon bool, version uint32, cxDesired int, cyDesired int, flags uint) (uintptr, error) {
+func CreateIconFromResourceEx(presbits uintptr, dwResSize uint32, isIcon bool, version uint32, cxDesired int, cyDesired int, flags uint) (HICON, error) {
icon := 0
if isIcon {
icon = 1
@@ -24,12 +24,12 @@ func CreateIconFromResourceEx(presbits uintptr, dwResSize uint32, isIcon bool, v
if r == 0 {
return 0, err
}
- return r, nil
+ return HICON(r), nil
}
// CreateHIconFromPNG creates a HICON from a PNG file
func CreateHIconFromPNG(pngData []byte) (HICON, error) {
- icon, err := CreateIconFromResourceEx(
+ return CreateIconFromResourceEx(
uintptr(unsafe.Pointer(&pngData[0])),
uint32(len(pngData)),
true,
@@ -37,5 +37,4 @@ func CreateHIconFromPNG(pngData []byte) (HICON, error) {
0,
0,
LR_DEFAULTSIZE)
- return HICON(icon), err
}
diff --git a/v2/pkg/assetserver/webview/webkit2_36+.go b/v2/pkg/assetserver/webview/webkit2_36+.go
index 1f0db3c89..e551ddbe4 100644
--- a/v2/pkg/assetserver/webview/webkit2_36+.go
+++ b/v2/pkg/assetserver/webview/webkit2_36+.go
@@ -1,9 +1,9 @@
-//go:build linux && (webkit2_36 || webkit2_40 || webkit2_41 )
+//go:build linux && (webkit2_36 || webkit2_40 || webkit2_41)
package webview
/*
-#cgo linux pkg-config: gtk+-3.0
+#cgo linux pkg-config: gtk+-3.0
#cgo !webkit2_41 pkg-config: webkit2gtk-4.0 libsoup-2.4
#cgo webkit2_41 pkg-config: webkit2gtk-4.1 libsoup-3.0
diff --git a/v2/pkg/options/options.go b/v2/pkg/options/options.go
index 0f62d5e4b..e70caf0b6 100644
--- a/v2/pkg/options/options.go
+++ b/v2/pkg/options/options.go
@@ -32,17 +32,19 @@ type Experimental struct{}
// App contains options for creating the App
type App struct {
- Title string
- Width int
- Height int
- DisableResize bool
- Fullscreen bool
- Frameless bool
- MinWidth int
- MinHeight int
- MaxWidth int
- MaxHeight int
- StartHidden bool
+ Title string
+ Width int
+ Height int
+ DisableResize bool
+ Fullscreen bool
+ Frameless bool
+ MinWidth int
+ MinHeight int
+ MaxWidth int
+ MaxHeight int
+ StartHidden bool
+ // HideWindowOnClose controls close button behavior.
+ // If true and a tray icon is configured, close behaves like HideWindowAndDock.
HideWindowOnClose bool
AlwaysOnTop bool
// BackgroundColour is the background colour of the window
@@ -55,6 +57,7 @@ type App struct {
// AssetServer configures the Assets for the application
AssetServer *assetserver.Options
Menu *menu.Menu
+ Tray *menu.TrayMenu
Logger logger.Logger `json:"-"`
LogLevel logger.LogLevel
LogLevelProduction logger.LogLevel
diff --git a/v2/pkg/runtime/tray.go b/v2/pkg/runtime/tray.go
new file mode 100644
index 000000000..7fb8e0863
--- /dev/null
+++ b/v2/pkg/runtime/tray.go
@@ -0,0 +1,13 @@
+package runtime
+
+import (
+ "context"
+
+ "github.com/wailsapp/wails/v2/pkg/menu"
+)
+
+// TraySetSystemTray sets the system tray menu
+func TraySetSystemTray(ctx context.Context, trayMenu *menu.TrayMenu) {
+ frontend := getFrontend(ctx)
+ frontend.TraySetSystemTray(trayMenu)
+}
diff --git a/v2/pkg/runtime/tray_test.go b/v2/pkg/runtime/tray_test.go
new file mode 100644
index 000000000..43d74e265
--- /dev/null
+++ b/v2/pkg/runtime/tray_test.go
@@ -0,0 +1,95 @@
+package runtime
+
+import (
+ "context"
+ "testing"
+
+ "github.com/wailsapp/wails/v2/internal/frontend"
+ "github.com/wailsapp/wails/v2/pkg/menu"
+ "github.com/wailsapp/wails/v2/pkg/options"
+)
+
+type mockFrontend struct {
+ frontend.Frontend
+ trayMenu *menu.TrayMenu
+}
+
+func (m *mockFrontend) TraySetSystemTray(trayMenu *menu.TrayMenu) {
+ m.trayMenu = trayMenu
+}
+
+func (m *mockFrontend) Run(ctx context.Context) error { return nil }
+func (m *mockFrontend) RunMainLoop() {}
+func (m *mockFrontend) ExecJS(js string) {}
+func (m *mockFrontend) Hide() {}
+func (m *mockFrontend) Show() {}
+func (m *mockFrontend) Quit() {}
+
+func (m *mockFrontend) OpenFileDialog(dialogOptions frontend.OpenDialogOptions) (string, error) {
+ return "", nil
+}
+func (m *mockFrontend) OpenMultipleFilesDialog(dialogOptions frontend.OpenDialogOptions) ([]string, error) {
+ return nil, nil
+}
+func (m *mockFrontend) OpenDirectoryDialog(dialogOptions frontend.OpenDialogOptions) (string, error) {
+ return "", nil
+}
+func (m *mockFrontend) SaveFileDialog(dialogOptions frontend.SaveDialogOptions) (string, error) {
+ return "", nil
+}
+func (m *mockFrontend) MessageDialog(dialogOptions frontend.MessageDialogOptions) (string, error) {
+ return "", nil
+}
+
+func (m *mockFrontend) WindowSetTitle(title string) {}
+func (m *mockFrontend) WindowShow() {}
+func (m *mockFrontend) WindowHide() {}
+func (m *mockFrontend) WindowCenter() {}
+func (m *mockFrontend) WindowToggleMaximise() {}
+func (m *mockFrontend) WindowMaximise() {}
+func (m *mockFrontend) WindowUnmaximise() {}
+func (m *mockFrontend) WindowMinimise() {}
+func (m *mockFrontend) WindowUnminimise() {}
+func (m *mockFrontend) WindowSetAlwaysOnTop(b bool) {}
+func (m *mockFrontend) WindowSetPosition(x int, y int) {}
+func (m *mockFrontend) WindowGetPosition() (int, int) { return 0, 0 }
+func (m *mockFrontend) WindowSetSize(width int, height int) {}
+func (m *mockFrontend) WindowGetSize() (int, int) { return 0, 0 }
+func (m *mockFrontend) WindowSetMinSize(width int, height int) {}
+func (m *mockFrontend) WindowSetMaxSize(width int, height int) {}
+func (m *mockFrontend) WindowFullscreen() {}
+func (m *mockFrontend) WindowUnfullscreen() {}
+func (m *mockFrontend) WindowSetBackgroundColour(col *options.RGBA) {}
+func (m *mockFrontend) WindowReload() {}
+func (m *mockFrontend) WindowReloadApp() {}
+func (m *mockFrontend) WindowSetSystemDefaultTheme() {}
+func (m *mockFrontend) WindowSetLightTheme() {}
+func (m *mockFrontend) WindowSetDarkTheme() {}
+func (m *mockFrontend) WindowIsMaximised() bool { return false }
+func (m *mockFrontend) WindowIsMinimised() bool { return false }
+func (m *mockFrontend) WindowIsNormal() bool { return false }
+func (m *mockFrontend) WindowIsFullscreen() bool { return false }
+func (m *mockFrontend) WindowClose() {}
+func (m *mockFrontend) WindowPrint() {}
+func (m *mockFrontend) ScreenGetAll() ([]frontend.Screen, error) { return nil, nil }
+func (m *mockFrontend) MenuSetApplicationMenu(menu *menu.Menu) {}
+func (m *mockFrontend) MenuUpdateApplicationMenu() {}
+func (m *mockFrontend) Notify(name string, data ...interface{}) {}
+func (m *mockFrontend) BrowserOpenURL(url string) {}
+func (m *mockFrontend) ClipboardGetText() (string, error) { return "", nil }
+func (m *mockFrontend) ClipboardSetText(text string) error { return nil }
+
+func TestTraySetSystemTray(t *testing.T) {
+ mock := &mockFrontend{}
+ ctx := context.WithValue(context.Background(), "frontend", mock)
+
+ trayMenu := &menu.TrayMenu{
+ Label: "Test",
+ }
+
+ TraySetSystemTray(ctx, trayMenu)
+
+ if mock.trayMenu != trayMenu {
+ t.Errorf("Expected tray menu to be set")
+ }
+}
diff --git a/website/docs/reference/options.mdx b/website/docs/reference/options.mdx
index 8651a3205..7859b2b8f 100644
--- a/website/docs/reference/options.mdx
+++ b/website/docs/reference/options.mdx
@@ -42,6 +42,7 @@ func main() {
Middleware: assetsMidldeware,
},
Menu: app.applicationMenu(),
+ Tray: app.trayMenu(),
Logger: nil,
LogLevel: logger.DEBUG,
LogLevelProduction: logger.ERROR,
@@ -248,6 +249,19 @@ hide the window instead.
Name: HideWindowOnClose
Type: `bool`
+### WindowCloseBehaviour
+
+This defines the behavior of the window when it is closed.
+
+| Value | Description |
+| :--- | :--- |
+| `options.CloseWindow` | Closes the window and the application (Default) |
+| `options.HideWindow` | Hides the window instead of closing it |
+| `options.HideWindowAndDock` | (macOS only) Hides the window and the app stays in the dock |
+
+Name: WindowCloseBehaviour
+Type: `options.WindowCloseBehaviour`
+
### BackgroundColour
This value is the default background colour of the window.
@@ -365,6 +379,13 @@ On Mac, if no menu is specified, a default menu will be created.
Name: Menu
Type: `*menu.Menu`
+### Tray
+
+The tray menu to be used by the application. More details about Tray in the [Tray Reference](../reference/runtime/tray.mdx).
+
+Name: Tray
+Type: `*menu.TrayMenu`
+
### Logger
The logger to be used by the application. More details about logging in the [Log Reference](../reference/runtime/log.mdx).
diff --git a/website/docs/reference/runtime/intro.mdx b/website/docs/reference/runtime/intro.mdx
index d67e76c64..df0644cad 100644
--- a/website/docs/reference/runtime/intro.mdx
+++ b/website/docs/reference/runtime/intro.mdx
@@ -16,6 +16,7 @@ It has utility methods for:
- [Browser](browser.mdx)
- [Log](log.mdx)
- [Clipboard](clipboard.mdx)
+- [Tray](tray.mdx)
The Go Runtime is available through importing `github.com/wailsapp/wails/v2/pkg/runtime`. All methods in this package
take a context as the first parameter. This context should be obtained from the [OnStartup](../options.mdx#onstartup)
diff --git a/website/docs/reference/runtime/tray.mdx b/website/docs/reference/runtime/tray.mdx
new file mode 100644
index 000000000..fdf79fa6b
--- /dev/null
+++ b/website/docs/reference/runtime/tray.mdx
@@ -0,0 +1,37 @@
+---
+sidebar_position: 11
+---
+
+# Tray
+
+These methods give control of the system tray.
+
+### TraySetSystemTray
+
+Sets the system tray menu.
+
+Go: `TraySetSystemTray(ctx context.Context, trayMenu *menu.TrayMenu)`
+
+#### TrayMenu
+
+The `TrayMenu` struct defines the system tray icon and its associated menu.
+
+| Field | Type | Description |
+| :--- | :--- | :--- |
+| Label | string | The text to display in the tray (platform dependent) |
+| Image | string | The name of the tray icon or base64 image data |
+| MacTemplateImage | bool | (macOS only) Indicates if the image is a template image |
+| Tooltip | string | The tooltip text to show when hovering over the tray icon |
+| Menu | [*Menu](./menu.mdx) | The menu to display when the tray icon is clicked |
+
+:::info Windows
+On Windows, the icon is displayed in the notification area.
+:::
+
+:::info macOS
+On macOS, the icon is displayed in the menu bar. If `MacTemplateImage` is true, the icon will automatically adapt to light/dark mode.
+:::
+
+:::info Linux
+On Linux, support depends on the desktop environment and its support for system tray icons (StatusNotifierItem).
+:::
diff --git a/website/src/pages/changelog.mdx b/website/src/pages/changelog.mdx
index 50208109b..3467ffc01 100644
--- a/website/src/pages/changelog.mdx
+++ b/website/src/pages/changelog.mdx
@@ -14,6 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+- Added system tray runtime wrapper and platform implementations by [@SomeoneIsWorking](https://github.com/SomeoneIsWorking) in [PR](https://github.com/wailsapp/wails/pull/4991)
+
### Fixed
- Fixed `wails init` to prevent initialization in non-empty directories when using the `-d` flag, avoiding accidental data loss [`#4940`](https://github.com/wailsapp/wails/issues/4940) by `@leaanthony`