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`