This commit is contained in:
Frog Business 2026-03-14 13:37:09 +00:00 committed by GitHub
commit b214423760
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 1414 additions and 105 deletions

4
v2/examples/tray-icon/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
build/bin
node_modules
frontend/dist
frontend/wailsjs

View file

@ -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
```

View file

@ -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
}

View file

@ -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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

View file

@ -0,0 +1,68 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>{{.Info.ProductName}}</string>
<key>CFBundleExecutable</key>
<string>{{.OutputFilename}}</string>
<key>CFBundleIdentifier</key>
<string>com.wails.{{.Name}}</string>
<key>CFBundleVersion</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleGetInfoString</key>
<string>{{.Info.Comments}}</string>
<key>CFBundleShortVersionString</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleIconFile</key>
<string>iconfile</string>
<key>LSMinimumSystemVersion</key>
<string>10.13.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>{{.Info.Copyright}}</string>
{{if .Info.FileAssociations}}
<key>CFBundleDocumentTypes</key>
<array>
{{range .Info.FileAssociations}}
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>{{.Ext}}</string>
</array>
<key>CFBundleTypeName</key>
<string>{{.Name}}</string>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
<key>CFBundleTypeIconFile</key>
<string>{{.IconName}}</string>
</dict>
{{end}}
</array>
{{end}}
{{if .Info.Protocols}}
<key>CFBundleURLTypes</key>
<array>
{{range .Info.Protocols}}
<dict>
<key>CFBundleURLName</key>
<string>com.wails.{{.Scheme}}</string>
<key>CFBundleURLSchemes</key>
<array>
<string>{{.Scheme}}</string>
</array>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
</dict>
{{end}}
</array>
{{end}}
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
</dict>
</plist>

View file

@ -0,0 +1,63 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>{{.Info.ProductName}}</string>
<key>CFBundleExecutable</key>
<string>{{.OutputFilename}}</string>
<key>CFBundleIdentifier</key>
<string>com.wails.{{.Name}}</string>
<key>CFBundleVersion</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleGetInfoString</key>
<string>{{.Info.Comments}}</string>
<key>CFBundleShortVersionString</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleIconFile</key>
<string>iconfile</string>
<key>LSMinimumSystemVersion</key>
<string>10.13.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>{{.Info.Copyright}}</string>
{{if .Info.FileAssociations}}
<key>CFBundleDocumentTypes</key>
<array>
{{range .Info.FileAssociations}}
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>{{.Ext}}</string>
</array>
<key>CFBundleTypeName</key>
<string>{{.Name}}</string>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
<key>CFBundleTypeIconFile</key>
<string>{{.IconName}}</string>
</dict>
{{end}}
</array>
{{end}}
{{if .Info.Protocols}}
<key>CFBundleURLTypes</key>
<array>
{{range .Info.Protocols}}
<dict>
<key>CFBundleURLName</key>
<string>com.wails.{{.Scheme}}</string>
<key>CFBundleURLSchemes</key>
<array>
<string>{{.Scheme}}</string>
</array>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
</dict>
{{end}}
</array>
{{end}}
</dict>
</plist>

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View file

@ -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}}"
}
}
}

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity type="win32" name="com.wails.{{.Name}}" version="{{.Info.ProductVersion}}.0" processorArchitecture="*"/>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
</dependentAssembly>
</dependency>
<asmv3:application>
<asmv3:windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
</asmv3:windowsSettings>
</asmv3:application>
</assembly>

View file

@ -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 => ../../

View file

@ -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=

View file

@ -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)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View file

@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="tray icon alt">
<defs>
<linearGradient id="bg2" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#0f766e"/>
<stop offset="100%" stop-color="#115e59"/>
</linearGradient>
</defs>
<circle cx="32" cy="32" r="28" fill="url(#bg2)"/>
<circle cx="32" cy="32" r="16" fill="#f8fafc" opacity="0.92"/>
<path d="M20 45h24v4H20z" fill="#f59e0b"/>
<path d="M22 20h20v4H22z" fill="#e2e8f0"/>
<rect x="29" y="23" width="6" height="18" rx="3" fill="#0f172a"/>
</svg>

After

Width:  |  Height:  |  Size: 576 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View file

@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="tray icon">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#1f2937"/>
<stop offset="100%" stop-color="#111827"/>
</linearGradient>
</defs>
<rect x="4" y="4" width="56" height="56" rx="14" fill="url(#bg)"/>
<circle cx="32" cy="32" r="21" fill="#10b981" opacity="0.15"/>
<path d="M19 45V19h11.5c6.7 0 10.5 3.5 10.5 9.4 0 4.9-2.9 8-8 8h-6.2V45H19zm7.8-14.9h3.6c2.2 0 3.4-1.2 3.4-3.1 0-2-1.2-3.1-3.4-3.1h-3.6v6.2z" fill="#e5e7eb"/>
<rect x="38" y="37" width="10" height="10" rx="3" fill="#f59e0b"/>
</svg>

After

Width:  |  Height:  |  Size: 660 B

View file

@ -0,0 +1,5 @@
{
"$schema": "https://wails.io/schemas/config.v2.json",
"name": "tray-icon",
"outputfilename": "tray-icon"
}

View file

@ -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 {

View file

@ -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
}

View file

@ -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 {

View file

@ -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 {

View file

@ -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);

View file

@ -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);

View file

@ -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;

View file

@ -109,6 +109,10 @@ typedef void (^schemeTaskCaller)(id<WKURLSchemeTask>);
[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<WKURLSchemeTask>);
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<WKURLSchemeTask>);
}
- (void) Show {
if ([NSApp activationPolicy] == NSApplicationActivationPolicyAccessory) {
[NSApp setActivationPolicy:NSApplicationActivationPolicyRegular];
}
[self.mainWindow makeKeyAndOrderFront:nil];
[NSApp activateIgnoringOtherApps:YES];
}

View file

@ -12,7 +12,7 @@
@interface WindowDelegate : NSObject <NSWindowDelegate>
@property bool hideOnClose;
@property int hideOnClose;
@property (assign) WailsContext* ctx;

View file

@ -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;
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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)
}

View file

@ -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})
}
})
}

View file

@ -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)
}

View file

@ -1,11 +1,13 @@
#include <JavaScriptCore/JavaScript.h>
#include <gtk/gtk.h>
#include <libayatana-appindicator/app-indicator.h>
#include <webkit2/webkit2.h>
#include <stdio.h>
#include <limits.h>
#include <stdint.h>
#include <string.h>
#include <locale.h>
#include <unistd.h>
#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;
}
}

View file

@ -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 <JavaScriptCore/JavaScript.h>
#include <gtk/gtk.h>
#include <libayatana-appindicator/app-indicator.h>
#include <webkit2/webkit2.h>
#include <stdio.h>
#include <limits.h>
@ -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() {

View file

@ -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 */

View file

@ -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)
}
}

View file

@ -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")

View file

@ -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)

View file

@ -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)
}

View file

@ -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),

View file

@ -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())
}

View file

@ -129,6 +129,7 @@ type Frontend interface {
// Menus
MenuSetApplicationMenu(menu *menu.Menu)
MenuUpdateApplicationMenu()
TraySetSystemTray(trayMenu *menu.TrayMenu)
// Events
Notify(name string, data ...interface{})

View file

@ -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")
)

View file

@ -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")
}
}

View file

@ -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
}

View file

@ -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

View file

@ -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

13
v2/pkg/runtime/tray.go Normal file
View file

@ -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)
}

View file

@ -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")
}
}

View file

@ -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<br/>
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<br/>
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<br/>
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<br/>
Type: `*menu.TrayMenu`
### Logger
The logger to be used by the application. More details about logging in the [Log Reference](../reference/runtime/log.mdx).

View file

@ -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)

View file

@ -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).
:::

View file

@ -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`