diff --git a/README.md b/README.md index 36392b0..e225e41 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ Verstak uses the Microsoft WebView2 Runtime. It is already included with Windows ## Background and tray mode -Closing the main window keeps Verstak running in the system tray. Use **Show Verstak** to bring it back and **Quit** to exit the application completely. This lets scheduled Todo reminders continue while the window is hidden. +After the tray icon is ready, closing the main window keeps Verstak running in the system tray. One left click restores and focuses the window; a right click opens the native **Show Verstak** and **Quit** menu. **Quit** exits the application completely. If the tray cannot start, closing the window exits normally rather than leaving an unreachable process. Scheduled Todo reminders continue while the window is hidden. ### Verify a download @@ -234,7 +234,6 @@ Each vault is connected separately. The local vault remains the primary copy of * Git; * Wails v2 build dependencies; * WebKitGTK development packages on Linux. -* Ayatana AppIndicator development files on Linux (`sudo apt install libayatana-appindicator3-dev`). See the [Wails installation documentation](https://wails.io/docs/gettingstarted/installation/) diff --git a/README.ru.md b/README.ru.md index 71dec6f..47ff8e8 100644 --- a/README.ru.md +++ b/README.ru.md @@ -124,7 +124,7 @@ APPIMAGE_EXTRACT_AND_RUN=1 ./verstak-linux-x86_64-*.AppImage ## Фоновая работа и трей -Закрытие главного окна не завершает Верстак: приложение остаётся в системном трее. Выберите **«Показать Верстак»**, чтобы вернуть окно, или **«Выйти»**, чтобы полностью завершить приложение. Благодаря этому напоминания Todo продолжают работать, пока окно скрыто. +После готовности значка в трее закрытие главного окна не завершает Верстак. Один левый клик возвращает и фокусирует окно, правый клик открывает нативное меню **«Показать Верстак»** и **«Выйти»**. **«Выйти»** полностью завершает приложение. Если трей не удалось запустить, закрытие окна завершает приложение обычным образом и не оставляет недоступный процесс. Пока окно скрыто, напоминания Todo продолжают работать. ### Проверка скачанного файла @@ -234,7 +234,6 @@ sha256sum -c SHA256SUMS --ignore-missing * Git; * зависимости для сборки Wails v2; * пакеты разработки WebKitGTK в Linux. -* файлы разработки Ayatana AppIndicator в Linux (`sudo apt install libayatana-appindicator3-dev`). Зависимости для конкретного дистрибутива перечислены в [документации Wails](https://wails.io/docs/gettingstarted/installation/). diff --git a/docs/superpowers/plans/2026-07-14-native-notifications-and-tray.md b/docs/superpowers/plans/2026-07-14-native-notifications-and-tray.md index 0bd11d0..aab7b0b 100644 --- a/docs/superpowers/plans/2026-07-14-native-notifications-and-tray.md +++ b/docs/superpowers/plans/2026-07-14-native-notifications-and-tray.md @@ -6,12 +6,13 @@ **Architecture:** Desktop core stores and delivers plugin-owned notification schedules and controls the tray lifecycle. Todo uses the public plugin API to replace its desired reminders. A small core tray adapter keeps the Wails process alive after window close. Documentation uses the supplied English and Russian README sources plus screenshots from an actual test vault. -**Tech Stack:** Go 1.24, Wails v2.12 runtime notifications, `getlantern/systray`, plain JavaScript plugin API, Node smoke tests, Playwright, bash packaging. +**Tech Stack:** Go 1.24, Wails v2.12 runtime notifications, `fyne.io/systray`, plain JavaScript plugin API, Node smoke tests, Playwright, bash packaging. ## Global constraints - Support Windows and Linux only; no background daemon after an explicit Quit. -- Closing a window hides it; only tray **Quit** ends the process. +- Closing a window hides it only after the tray reports ready; otherwise normal + window close ends the process. - `verstak.todo` requires `verstak/core/notifications/v1` and `notifications.schedule`. - Plugins cannot receive Wails runtime access. - Keep Wails generated bindings out of commits. @@ -231,7 +232,12 @@ Expected: absent packages/methods. - [ ] **Step 3: Implement adapter and Wails wiring** -Add `github.com/getlantern/systray@v1.2.2`. The production adapter calls nonblocking `systray.Register`, sets compact source-controlled PNG bytes derived from the existing tracked logo, and starts goroutines for the two click channels. `main.go` registers `OnBeforeClose` and uses `options.SingleInstanceLock` whose second launch calls `app.ShowWindow`. Do not embed ignored Wails-generated files from `build/`. +Use `fyne.io/systray`. The production adapter calls `RunWithExternalLoop` so the +Windows native message loop runs alongside Wails, embeds a multi-resolution +ICO on Windows and PNG on Linux, routes one left click to `app.ShowWindow`, and +leaves the platform-native right-click menu active. `main.go` registers +`OnBeforeClose` and uses `options.SingleInstanceLock` whose second launch calls +`app.ShowWindow`. Do not embed ignored Wails-generated files from `build/`. - [ ] **Step 4: Verify and commit** @@ -239,7 +245,7 @@ Add `github.com/getlantern/systray@v1.2.2`. The production adapter calls nonbloc gofmt -w main.go internal/api/app.go internal/api/app_test.go internal/shell/tray/*.go GOCACHE=/tmp/verstak-go-cache go test ./internal/shell/tray ./internal/api -count=1 ./scripts/build.sh -ldd build/bin/verstak-desktop | grep -E 'ayatana-appindicator|appindicator' +! rg -n 'getlantern|appindicator' go.mod go.sum packaging scripts/build.sh git diff --check ``` @@ -265,7 +271,8 @@ git push mirror main - [ ] **Step 1: Add failing package-contract checks** -Add assertions for `libayatana-appindicator3-dev` in the Linux build guidance, `libayatana-appindicator3-1` in Debian dependencies, and `ayatana-appindicator` in the AppImage packing verification. +Add assertions that the Linux build guidance, Debian metadata, and AppImage +packager no longer require the removed AppIndicator backend. - [ ] **Step 2: Confirm red** @@ -275,7 +282,9 @@ Expected: the first tray dependency assertion fails. - [ ] **Step 3: Implement portable package support** -Declare the Debian runtime dependency. Make the Linux build error name the appindicator development header. Require the dynamically discovered appindicator library to appear in AppDir after the existing `ldd` traversal. Keep the Windows system-WebView2 policy unchanged. +Keep Debian and AppImage focused on the Wails/WebKitGTK runtime. Do not add a +tray-specific AppIndicator dependency. Keep the Windows system-WebView2 policy +unchanged. - [ ] **Step 4: Install the supplied public README sources** diff --git a/docs/superpowers/specs/2026-07-14-native-notifications-and-tray-design.md b/docs/superpowers/specs/2026-07-14-native-notifications-and-tray-design.md index cee4df1..29c4328 100644 --- a/docs/superpowers/specs/2026-07-14-native-notifications-and-tray-design.md +++ b/docs/superpowers/specs/2026-07-14-native-notifications-and-tray-design.md @@ -1,6 +1,6 @@ # Native Notifications and System Tray Design -**Status:** approved for implementation on 2026-07-14 +**Status:** implemented; tray reliability update recorded on 2026-07-15 ## Goal @@ -19,27 +19,33 @@ must work in the portable Windows archive, Debian package, and AppImage. ## Tray behavior -On Windows and Linux, a tray icon is registered before the Wails event loop -starts. Its menu contains exactly two actions: +On Windows and Linux, a tray icon is initialized after Wails reaches +`OnDomReady`. Its menu contains exactly two actions: 1. **Show Verstak** — shows and focuses the existing main window. 2. **Quit** — exits the process deliberately. -Closing the main window with its window-manager close control hides the window -and keeps the process, plugins, local browser receiver, and reminder scheduler -alive. It does not terminate the application. The quit action temporarily -allows the close lifecycle to finish and then exits normally. +One left click restores and focuses the existing main window. A native right +click opens the menu. Closing the main window with its window-manager close +control hides the window only after the tray has successfully initialized; it +then keeps the process, plugins, local browser receiver, and reminder scheduler +alive. If tray initialization fails or the native message loop exits, the +ordinary close path exits normally rather than leaving an unreachable process. +The quit action allows the close lifecycle to finish and exits normally. The app has a single-instance lock. If a user launches the executable while an instance is hidden in the tray, the existing instance shows its window instead of creating a second process. -The implementation uses `github.com/getlantern/systray` through a small -`internal/shell/tray` adapter. It uses `Register`, rather than its blocking -`Run`, so Wails remains the owner of the GUI event loop. A compact PNG derived -from the tracked project logo is encoded in the tray package, so a clean build -does not depend on ignored Wails-generated icon files. The library accepts PNG -icon bytes on both target platforms. +The implementation uses `fyne.io/systray` through a small +`internal/shell/tray` adapter. `RunWithExternalLoop` starts the Windows native +message loop without making Wails relinquish ownership of its GUI lifecycle. +The icon is a source-controlled multi-resolution ICO on Windows (16, 20, 24, +32, 48, and 256 pixels with transparency) and a PNG on Linux. Both are embedded +in the binary, so a clean build does not depend on ignored Wails-generated +files. Tray readiness is published only after icon, tooltip, and menu creation +all succeed; lifecycle diagnostics are logged for startup, readiness, clicks, +failure fallback, and shutdown. ## Notification capability and permission @@ -116,16 +122,14 @@ is not removed. ## Packaging -`getlantern/systray` requires CGO. The Windows release build already uses -`x86_64-w64-mingw32-gcc`; the Windows packaging tests must compile it with the -tray dependency included. Linux build instructions add -`libayatana-appindicator3-dev`. The Debian package declares the corresponding -runtime dependency `libayatana-appindicator3-1`. +`fyne.io/systray` supplies the Windows message loop and uses the session D-Bus +on Linux. It does not require the removed AppIndicator development or runtime +package. The Windows release build still uses `x86_64-w64-mingw32-gcc` for the +Wails application itself. The existing AppImage packager traverses `ldd` for the desktop executable and -copies non-glibc runtime libraries. Its verification is extended to prove that -the appindicator library is present in the AppDir when the tray implementation -is compiled in. +copies non-glibc runtime libraries; it does not require a tray-specific shared +library. ## Public README and product screenshots @@ -156,15 +160,19 @@ Automated tests cover: - schedule replacement, cancellation, rescheduling, persistence, one-time overdue delivery, failed-send retry, and permission/capability rejection; - Todo desired-list derivation and calls after create/edit/status/delete; -- close policy: ordinary close hides, explicit quit permits shutdown; -- tray controller action wiring and second-instance window reveal; -- Linux/Windows build scripts and package dependency expectations. +- close policy: ordinary close hides only after tray readiness, while explicit + quit permits shutdown; +- tray controller action wiring, readiness/failure fallback, idempotent stop, + left-click reveal, and second-instance window reveal; +- true multi-resolution Windows ICO data, Linux PNG data, and Linux/Windows + build and package dependency expectations. Manual smoke tests are required because neither unit tests nor Playwright can assert a real desktop notification area or OS toast: -1. On Linux and Windows, start Verstak, close its window, use the tray menu to - reveal it, and use **Quit** to terminate it. +1. On Linux and Windows, start Verstak, verify the tray icon and tooltip, use + one left click to reveal the window, use the right-click menu to reveal it, + close the window, and use **Quit** to terminate it. 2. Set a Todo reminder for a near future time, hide the window in the tray, and observe one native notification. 3. Quit before a future reminder, relaunch after it expires, and observe one diff --git a/go.mod b/go.mod index d593303..e611162 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/verstak/verstak-desktop go 1.24.4 require ( - github.com/getlantern/systray v1.2.2 + fyne.io/systray v1.12.2 github.com/google/uuid v1.6.0 github.com/wailsapp/wails/v2 v2.12.0 golang.org/x/net v0.35.0 @@ -12,14 +12,7 @@ require ( require ( git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect github.com/bep/debounce v1.2.1 // indirect - github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect - github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect - github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect - github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect - github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect - github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect github.com/go-ole/go-ole v1.3.0 // indirect - github.com/go-stack/stack v1.8.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect @@ -31,7 +24,6 @@ require ( 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/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // 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 diff --git a/go.sum b/go.sum index 7e18663..03679de 100644 --- a/go.sum +++ b/go.sum @@ -1,28 +1,13 @@ +fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA= +fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= github.com/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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4= -github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY= -github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So= -github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A= -github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk= -github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc= -github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0= -github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o= -github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc= -github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA= -github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA= -github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA= -github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE= -github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE= 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/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 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= @@ -45,8 +30,6 @@ github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/ 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/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ= -github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk= 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= @@ -55,8 +38,6 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk 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/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw= -github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0= 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= @@ -68,9 +49,6 @@ 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/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 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= @@ -91,7 +69,6 @@ golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qx 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-20201018230417-eeed37f84f13/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= @@ -104,6 +81,5 @@ 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/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E= 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/internal/api/app.go b/internal/api/app.go index 9923168..44b8fa5 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -11,6 +11,7 @@ import ( "path/filepath" "sort" "strings" + "sync" "sync/atomic" "time" @@ -100,6 +101,8 @@ type App struct { browserInboxEvents map[string]bool browserInboxEnabled atomic.Bool allowQuit atomic.Bool + trayReady atomic.Bool + quitOnce sync.Once } // SetNotificationService attaches the core-owned plugin notification scheduler. @@ -221,11 +224,23 @@ func (a *App) Shutdown(ctx context.Context) { cleanupNativeNotifications(ctx) } -// BeforeClose hides the primary window until the user explicitly quits from the tray. +// SetTrayReady reports whether the native tray can safely return a hidden window. +func (a *App) SetTrayReady(ready bool) { + if a != nil { + a.trayReady.Store(ready) + } +} + +// BeforeClose hides the primary window only after the native tray has confirmed +// that it can return the window. Otherwise the normal Wails close path exits. func (a *App) BeforeClose(ctx context.Context) bool { if a.allowQuit.Load() { return false } + if !a.trayReady.Load() { + log.Printf("[app] tray is unavailable; allowing normal window close") + return false + } hideNativeWindow(ctx) return true } @@ -243,10 +258,12 @@ func (a *App) Quit() { if a == nil { return } - a.allowQuit.Store(true) - if a.ctx != nil { - quitNativeApplication(a.ctx) - } + a.quitOnce.Do(func() { + a.allowQuit.Store(true) + if a.ctx != nil { + quitNativeApplication(a.ctx) + } + }) } // NativeNotificationSender delivers scheduler items through the Wails runtime. diff --git a/internal/api/app_test.go b/internal/api/app_test.go index 257498b..c611d4a 100644 --- a/internal/api/app_test.go +++ b/internal/api/app_test.go @@ -319,7 +319,7 @@ func TestNativeNotificationSenderUsesStablePluginScopedID(t *testing.T) { } } -func TestBeforeCloseHidesWindowUntilUserChoosesQuit(t *testing.T) { +func TestBeforeCloseAllowsExitUntilTrayIsReady(t *testing.T) { oldHide := hideNativeWindow defer func() { hideNativeWindow = oldHide }() @@ -327,8 +327,25 @@ func TestBeforeCloseHidesWindowUntilUserChoosesQuit(t *testing.T) { hideNativeWindow = func(context.Context) { hideCalls++ } app := &App{} + if prevent := app.BeforeClose(context.Background()); prevent { + t.Fatal("BeforeClose() = true, want false while tray is unavailable") + } + if hideCalls != 0 { + t.Fatalf("hide calls = %d, want 0", hideCalls) + } +} + +func TestBeforeCloseHidesWindowAfterTrayIsReady(t *testing.T) { + oldHide := hideNativeWindow + defer func() { hideNativeWindow = oldHide }() + + hideCalls := 0 + hideNativeWindow = func(context.Context) { hideCalls++ } + app := &App{} + app.SetTrayReady(true) + if prevent := app.BeforeClose(context.Background()); !prevent { - t.Fatal("BeforeClose() = false, want true while tray mode is active") + t.Fatal("BeforeClose() = false, want true while tray is ready") } if hideCalls != 1 { t.Fatalf("hide calls = %d, want 1", hideCalls) @@ -353,6 +370,22 @@ func TestTrayQuitAllowsWindowCloseAndQuitsApplication(t *testing.T) { } } +func TestTrayQuitRequestsNativeShutdownOnlyOnce(t *testing.T) { + oldQuit := quitNativeApplication + defer func() { quitNativeApplication = oldQuit }() + + quitCalls := 0 + quitNativeApplication = func(context.Context) { quitCalls++ } + app := &App{ctx: context.Background()} + + app.Quit() + app.Quit() + + if quitCalls != 1 { + t.Fatalf("native quit calls = %d, want 1", quitCalls) + } +} + func TestShowWindowUsesWailsContext(t *testing.T) { oldShow := showNativeWindow defer func() { showNativeWindow = oldShow }() diff --git a/internal/shell/tray/controller.go b/internal/shell/tray/controller.go index e4a38c0..9554f84 100644 --- a/internal/shell/tray/controller.go +++ b/internal/shell/tray/controller.go @@ -2,10 +2,15 @@ package tray import ( + "errors" + "log" "strings" "sync" + "sync/atomic" ) +var errBackendUnavailable = errors.New("tray backend is unavailable") + // MenuItem exposes click events from a native tray menu item. type MenuItem interface { Clicked() <-chan struct{} @@ -13,13 +18,22 @@ type MenuItem interface { SetTooltip(tooltip string) } +// BackendCallbacks are invoked by the native tray implementation. +// A backend keeps the platform-native secondary-click handler enabled so the +// operating system opens the menu on a right click. +type BackendCallbacks struct { + Ready func() + Exit func() + LeftClick func() +} + // Backend is the platform tray implementation. type Backend interface { - Register(onReady func(), onExit func()) - SetIcon(icon []byte) - SetTooltip(tooltip string) - AddMenuItem(title, tooltip string) MenuItem - Quit() + Start(callbacks BackendCallbacks) error + SetIcon(icon []byte) error + SetTooltip(tooltip string) error + AddMenuItem(title, tooltip string) (MenuItem, error) + Stop() } // Actions are executed from the native tray menu. @@ -80,11 +94,20 @@ func LabelsForPreference(preference string, systemLocales ...string) Labels { type Controller struct { backend Backend icon []byte - start sync.Once - mu sync.RWMutex - labels Labels - show MenuItem - quit MenuItem + + startOnce sync.Once + readyOnce sync.Once + stopOnce sync.Once + started atomic.Bool + ready atomic.Bool + + mu sync.RWMutex + labels Labels + actions Actions + show MenuItem + quit MenuItem + startErr error + readyChanged func(bool) } // New creates a tray controller for one application process. @@ -96,6 +119,23 @@ func New(backend Backend, icon []byte) *Controller { } } +// SetReadyChangedHandler receives readiness changes after native initialization +// has either completed or ended. It lets the Wails close policy avoid hiding a +// window before the tray can bring it back. +func (c *Controller) SetReadyChangedHandler(handler func(bool)) { + if c == nil { + return + } + c.mu.Lock() + c.readyChanged = handler + c.mu.Unlock() +} + +// Ready reports whether the tray has completed icon and menu initialization. +func (c *Controller) Ready() bool { + return c != nil && c.ready.Load() +} + // SetLabels updates the current and future native tray menu labels. func (c *Controller) SetLabels(labels Labels) { if c == nil { @@ -108,35 +148,142 @@ func (c *Controller) SetLabels(labels Labels) { applyLabels(show, quit, labels) } -// Start registers the native tray without taking over the Wails event loop. -func (c *Controller) Start(actions Actions) { +// Start connects the tray to Wails without taking over Wails' GUI event loop. +// Ready remains false until the backend callback and all required menu setup +// finish successfully. +func (c *Controller) Start(actions Actions) error { if c == nil || c.backend == nil { + return errBackendUnavailable + } + c.startOnce.Do(func() { + c.mu.Lock() + c.actions = actions + c.mu.Unlock() + c.started.Store(true) + log.Printf("[tray] starting native backend") + err := c.backend.Start(BackendCallbacks{ + Ready: c.onReady, + Exit: c.onExit, + LeftClick: c.onLeftClick, + }) + if err != nil { + c.mu.Lock() + c.startErr = err + c.mu.Unlock() + c.started.Store(false) + c.setReady(false) + log.Printf("[tray] backend start failed: %v; falling back to normal window close", err) + } + }) + c.mu.RLock() + err := c.startErr + c.mu.RUnlock() + return err +} + +func (c *Controller) onReady() { + if c == nil { return } - c.start.Do(func() { - c.backend.Register(func() { - c.backend.SetIcon(c.icon) - c.backend.SetTooltip("Verstak") - c.mu.RLock() - labels := c.labels - c.mu.RUnlock() - show := c.backend.AddMenuItem(labels.ShowTitle, labels.ShowTooltip) - quit := c.backend.AddMenuItem(labels.QuitTitle, labels.QuitTooltip) - c.mu.Lock() - c.show, c.quit = show, quit - labels = c.labels - c.mu.Unlock() - applyLabels(show, quit, labels) - if actions.Show != nil && show != nil { - go routeClicks(show.Clicked(), actions.Show) + if !c.started.Load() { + log.Printf("[tray] ready callback ignored after backend startup failed") + return + } + c.readyOnce.Do(func() { + log.Printf("[tray] native backend reported ready") + if err := c.backend.SetIcon(c.icon); err != nil { + c.fail("icon setup", err) + return + } + if err := c.backend.SetTooltip("Verstak"); err != nil { + c.fail("tooltip setup", err) + return + } + c.mu.RLock() + labels := c.labels + actions := c.actions + c.mu.RUnlock() + show, err := c.backend.AddMenuItem(labels.ShowTitle, labels.ShowTooltip) + if err != nil || show == nil { + if err == nil { + err = errors.New("show menu item is nil") } - if actions.Quit != nil && quit != nil { - go routeClicks(quit.Clicked(), actions.Quit) + c.fail("show menu creation", err) + return + } + quit, err := c.backend.AddMenuItem(labels.QuitTitle, labels.QuitTooltip) + if err != nil || quit == nil { + if err == nil { + err = errors.New("quit menu item is nil") } - }, nil) + c.fail("quit menu creation", err) + return + } + c.mu.Lock() + c.show, c.quit = show, quit + labels = c.labels + c.mu.Unlock() + applyLabels(show, quit, labels) + if actions.Show != nil { + go routeClicks(show.Clicked(), func() { + log.Printf("[tray] Show command") + actions.Show() + }) + } + if actions.Quit != nil { + go routeClicks(quit.Clicked(), func() { + log.Printf("[tray] Quit command") + actions.Quit() + }) + } + c.setReady(true) + log.Printf("[tray] native tray is ready") }) } +func (c *Controller) onLeftClick() { + if c == nil { + return + } + log.Printf("[tray] left click") + if !c.Ready() { + log.Printf("[tray] left click ignored while tray is not ready") + return + } + c.mu.RLock() + show := c.actions.Show + c.mu.RUnlock() + if show != nil { + show() + } +} + +func (c *Controller) onExit() { + if c == nil { + return + } + c.setReady(false) + log.Printf("[tray] native message loop ended; falling back to normal window close") +} + +func (c *Controller) fail(stage string, err error) { + c.setReady(false) + log.Printf("[tray] %s failed: %v; falling back to normal window close", stage, err) + c.Stop() +} + +func (c *Controller) setReady(ready bool) { + if c == nil || c.ready.Swap(ready) == ready { + return + } + c.mu.RLock() + handler := c.readyChanged + c.mu.RUnlock() + if handler != nil { + handler(ready) + } +} + func applyLabels(show, quit MenuItem, labels Labels) { if show != nil { show.SetTitle(labels.ShowTitle) @@ -150,10 +297,14 @@ func applyLabels(show, quit MenuItem, labels Labels) { // Stop releases the native tray after Wails has begun application shutdown. func (c *Controller) Stop() { - if c == nil || c.backend == nil { + if c == nil || c.backend == nil || !c.started.Load() { return } - c.backend.Quit() + c.stopOnce.Do(func() { + c.setReady(false) + log.Printf("[tray] stopping native backend") + c.backend.Stop() + }) } func routeClicks(clicked <-chan struct{}, action func()) { diff --git a/internal/shell/tray/controller_test.go b/internal/shell/tray/controller_test.go index 2cedb46..6aadda5 100644 --- a/internal/shell/tray/controller_test.go +++ b/internal/shell/tray/controller_test.go @@ -1,6 +1,8 @@ package tray import ( + "errors" + "sync" "testing" "time" ) @@ -11,47 +13,65 @@ type fakeMenuItem struct { tooltip string } -func (i *fakeMenuItem) Clicked() <-chan struct{} { - return i.clicked -} - -func (i *fakeMenuItem) SetTitle(title string) { - i.title = title -} - -func (i *fakeMenuItem) SetTooltip(tooltip string) { - i.tooltip = tooltip -} +func (i *fakeMenuItem) Clicked() <-chan struct{} { return i.clicked } +func (i *fakeMenuItem) SetTitle(title string) { i.title = title } +func (i *fakeMenuItem) SetTooltip(tooltip string) { i.tooltip = tooltip } type fakeBackend struct { - icon []byte - tooltip string - items map[string]*fakeMenuItem - quitCalls int - registering bool + callbacks BackendCallbacks + icon []byte + tooltip string + items map[string]*fakeMenuItem + startErr error + iconErr error + menuErr error + starts int + stops int } -func (b *fakeBackend) Register(onReady func(), _ func()) { - b.registering = true - onReady() +func (b *fakeBackend) Start(callbacks BackendCallbacks) error { + b.starts++ + b.callbacks = callbacks + return b.startErr } -func (b *fakeBackend) SetIcon(icon []byte) { +func (b *fakeBackend) SetIcon(icon []byte) error { b.icon = append([]byte(nil), icon...) + return b.iconErr } -func (b *fakeBackend) SetTooltip(tooltip string) { +func (b *fakeBackend) SetTooltip(tooltip string) error { b.tooltip = tooltip + return nil } -func (b *fakeBackend) AddMenuItem(title, _ string) MenuItem { - item := &fakeMenuItem{clicked: make(chan struct{}, 1), title: title} +func (b *fakeBackend) AddMenuItem(title, tooltip string) (MenuItem, error) { + if b.menuErr != nil { + return nil, b.menuErr + } + item := &fakeMenuItem{clicked: make(chan struct{}, 1), title: title, tooltip: tooltip} b.items[title] = item - return item + return item, nil } -func (b *fakeBackend) Quit() { - b.quitCalls++ +func (b *fakeBackend) Stop() { b.stops++ } + +func (b *fakeBackend) ready() { + if b.callbacks.Ready != nil { + b.callbacks.Ready() + } +} + +func (b *fakeBackend) exited() { + if b.callbacks.Exit != nil { + b.callbacks.Exit() + } +} + +func (b *fakeBackend) leftClick() { + if b.callbacks.LeftClick != nil { + b.callbacks.LeftClick() + } } func waitFor(t *testing.T, signal <-chan struct{}) { @@ -63,64 +83,112 @@ func waitFor(t *testing.T, signal <-chan struct{}) { } } -func TestControllerInitializesTrayAndRoutesMenuActions(t *testing.T) { +func TestControllerBecomesReadyOnlyAfterBackendCallback(t *testing.T) { backend := &fakeBackend{items: make(map[string]*fakeMenuItem)} - showCalls := make(chan struct{}, 1) - quitCalls := make(chan struct{}, 1) controller := New(backend, []byte{1, 2, 3}) - controller.Start(Actions{ + if err := controller.Start(Actions{}); err != nil { + t.Fatalf("Start() error = %v", err) + } + if controller.Ready() { + t.Fatal("tray became ready before backend callback") + } + + backend.ready() + if !controller.Ready() { + t.Fatal("tray did not become ready after backend callback") + } + if string(backend.icon) != string([]byte{1, 2, 3}) || backend.tooltip != "Verstak" { + t.Fatalf("tray initialization = icon:%v tooltip:%q", backend.icon, backend.tooltip) + } +} + +func TestControllerRoutesLeftClickAndMenuActionsToShowAndQuit(t *testing.T) { + backend := &fakeBackend{items: make(map[string]*fakeMenuItem)} + showCalls := make(chan struct{}, 2) + quitCalls := make(chan struct{}, 1) + controller := New(backend, []byte{1}) + + if err := controller.Start(Actions{ Show: func() { showCalls <- struct{}{} }, Quit: func() { quitCalls <- struct{}{} }, - }) - - if !backend.registering || string(backend.icon) != string([]byte{1, 2, 3}) || backend.tooltip != "Verstak" { - t.Fatalf("tray initialization = registering:%t icon:%v tooltip:%q", backend.registering, backend.icon, backend.tooltip) + }); err != nil { + t.Fatalf("Start() error = %v", err) } + backend.ready() + + backend.leftClick() + waitFor(t, showCalls) showItem := backend.items["Show Verstak"] quitItem := backend.items["Quit"] if showItem == nil || quitItem == nil { t.Fatalf("tray menu = %#v, want Show Verstak and Quit", backend.items) } - showItem.clicked <- struct{}{} waitFor(t, showCalls) quitItem.clicked <- struct{}{} waitFor(t, quitCalls) } -func TestControllerStopsNativeTrayBackend(t *testing.T) { +func TestControllerStopCallsBackendOnce(t *testing.T) { backend := &fakeBackend{items: make(map[string]*fakeMenuItem)} controller := New(backend, []byte{1}) + if err := controller.Start(Actions{}); err != nil { + t.Fatalf("Start() error = %v", err) + } + controller.Stop() controller.Stop() - if backend.quitCalls != 1 { - t.Fatalf("backend quit calls = %d, want 1", backend.quitCalls) + if backend.stops != 1 { + t.Fatalf("backend stop calls = %d, want 1", backend.stops) } } -func TestControllerUsesAndUpdatesLocalizedLabels(t *testing.T) { +func TestControllerStartupAndSetupFailuresNeverBecomeReady(t *testing.T) { + for name, backend := range map[string]*fakeBackend{ + "start": {items: make(map[string]*fakeMenuItem), startErr: errors.New("start failed")}, + "icon": {items: make(map[string]*fakeMenuItem), iconErr: errors.New("icon failed")}, + "menu": {items: make(map[string]*fakeMenuItem), menuErr: errors.New("menu failed")}, + } { + t.Run(name, func(t *testing.T) { + controller := New(backend, []byte{1}) + err := controller.Start(Actions{}) + if name == "start" && err == nil { + t.Fatal("Start() error = nil, want startup failure") + } + if name != "start" && err != nil { + t.Fatalf("Start() error = %v, want nil before ready callback", err) + } + backend.ready() + if controller.Ready() { + t.Fatal("failed tray became ready") + } + }) + } +} + +func TestControllerExitRevokesReadinessAndKeepsLocalizedMenuItems(t *testing.T) { backend := &fakeBackend{items: make(map[string]*fakeMenuItem)} controller := New(backend, []byte{1}) controller.SetLabels(LabelsForPreference("ru")) - controller.Start(Actions{}) + if err := controller.Start(Actions{}); err != nil { + t.Fatalf("Start() error = %v", err) + } + backend.ready() show := backend.items["Показать Верстак"] quit := backend.items["Выйти"] if show == nil || quit == nil { t.Fatalf("Russian tray menu = %#v", backend.items) } - if show.tooltip != "Показать окно Верстака" || quit.tooltip != "Завершить Верстак" { - t.Fatalf("Russian tray tooltips = show:%q quit:%q", show.tooltip, quit.tooltip) - } - controller.SetLabels(LabelsForPreference("en")) if show.title != "Show Verstak" || quit.title != "Quit" { t.Fatalf("English tray menu after update = show:%q quit:%q", show.title, quit.title) } - if show.tooltip != "Show the Verstak window" || quit.tooltip != "Quit Verstak" { - t.Fatalf("English tray tooltips after update = show:%q quit:%q", show.tooltip, quit.tooltip) + backend.exited() + if controller.Ready() { + t.Fatal("tray stayed ready after backend exit") } } @@ -134,3 +202,26 @@ func TestLabelsForSystemRussianLocale(t *testing.T) { t.Fatalf("higher-priority system locale must win, got %#v", labels) } } + +func TestControllerReadyNotificationIsSafeAcrossCallbacks(t *testing.T) { + backend := &fakeBackend{items: make(map[string]*fakeMenuItem)} + controller := New(backend, []byte{1}) + var changes []bool + var mu sync.Mutex + controller.SetReadyChangedHandler(func(ready bool) { + mu.Lock() + changes = append(changes, ready) + mu.Unlock() + }) + if err := controller.Start(Actions{}); err != nil { + t.Fatalf("Start() error = %v", err) + } + backend.ready() + backend.exited() + + mu.Lock() + defer mu.Unlock() + if len(changes) != 2 || !changes[0] || changes[1] { + t.Fatalf("ready changes = %#v, want [true false]", changes) + } +} diff --git a/internal/shell/tray/icon.go b/internal/shell/tray/icon.go deleted file mode 100644 index 0a10bcc..0000000 --- a/internal/shell/tray/icon.go +++ /dev/null @@ -1,14 +0,0 @@ -package tray - -import "encoding/base64" - -const iconPNGBase64 = "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAACT1BMVEUAAACOjo7///8RERHY2NghISEDAwOjo6MODg7GxsYQEBDIyMgPDw/Hx8cKCgq9vb12dnaHh4fFxcV1dXWjo6OoqKinp6eNjY25ubnr6+v29vb29vb29vbx8fHV1dXm5ub9/f3z8/OUlJQAAADu7u74+Pi5ubkAAADu7u74+Pi6urq6urrs7Oz39/ezs7PZ2dn5+fn8/Pzq6uobGxtdXV3U1NTl5eXl5eXl5eXm5ubd3d2urq7////+/v7//v7//v/s7OzLy8vLy8ru7u708/POzc3Kysrd3d39/f3Z2dnU1NT7+/u0tLQpKSkoKCcnJycsLCy+vr63t7crKyskJCRzc3NQUFAlJSUoKChvb28gICAdHR0cHBzDwsJ6enoaGhkZGRltbW3MzMwqKioeHh6pqam+vb0iIiJGRkYbGxseHR0aGhpubm6cnJw6Ojrg4ODExMQmJiYeHh36+vppaWhxcXH8/PwvLy/a2tmKiorl5eU+Pj6zs7PQ0NA1NTXR0dFSUlIbGhq/v79BQUHn5+fW1tYuLi48PDyysrItLS02NjaNjY17e3v///7b29syMjJ/f38hISEqKSlqampaWlq9vb3f398cGxs9PT1KSko3NzeioqJRUVFJSUnk5OQaGRljY2OGhobp6ek/Pz+dnZ3IyMgoKSjHx8ZDQ0PHx8fx8fHw8PBVVVX39/cdHh2RkZHz8/NPT0+Ojo7Dw8PPz8/29vZUVFTCwsL19fX4+PhYWFgWFhYXFxfAwMCqqqqFhYWHhoaEhITe3t6MjIzi4uIwLXXJAAAAPHRSTlMAAAAAAAAAAAAAAAAAAAAAAAAABRIWFQoUis3R0K82b/22DQGZ2x0Cm9wfHo/UGULi+IIEAzZudHNzUA0lLC8OAAAAAWJLR0QCZgt8ZAAAAAd0SU1FB+oHDRIyN3EuEOkAAAHzSURBVDjLvdPXXxNBEAdwPHvvFewde8WyG3NGT41KlFxMwoyKguLeCCZRUCzRgIXYUexEjR17713/MPfCB+Vy8ZXfw+3tzvdlbueyslolbfr26z8gQwYqbVP1doMGD8nOsSU7Z+gwpb2sd1CGj2CZM3JURwk6jR4j37l5wDlvejrkssjBxo7rLEGX8RPk1qmqi1UXW7JU05bx5Ss0VXOvZBNzu/4Fq1bne9asZQVej+7jrnX+gB6UYNI/UFAIqK9nGzZC0Sa2uRihZAuzgK2lAB7VtU0YtJ2VIWD5DisI+QG87lAYIhTcuYsiVFFpBVo+4O49VQKAKvbuE1C6XzbWEhwoBowePBSDEvJV+0HUHE4DR44iHquN43G9yHdCROgkP2UFp8+gqDkLdcFzdL6ewH9Bfi8LuBjFS5ev0NVrAdEQRZFwpoPr5QiB8I2b1WHw1oFIMkcauHVbGAB4565sF+De/dS9tATOB8IwMO5uNEGs4aENPEpIQI+fhHR4CvjMrFvB8xcxA8RL9kqHCMZf28Gbt2RAoJG9k4Dq39vBh4+f6HPCxb4UElFZqm4FlV+/ff9Ry9nPqmTy128L6GYCR/OwNY9eE8jtLkEPZTL/z9BOmdrTHHtl2nSeMTNmzuplgt6z58zNm2dL3vwFC/u0yo/7B9C02RGfGBOeAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDI2LTA3LTEzVDE0OjIyOjMzKzAwOjAwJHU36gAAACV0RVh0ZGF0ZTptb2RpZnkAMjAyNi0wNy0xM1QxNDoyMjozMyswMDowMFUoj1YAAAAASUVORK5CYII=" - -// DefaultIcon returns the compact source-controlled Verstak tray icon. -func DefaultIcon() []byte { - icon, err := base64.StdEncoding.DecodeString(iconPNGBase64) - if err != nil { - return nil - } - return icon -} diff --git a/internal/shell/tray/icon_linux.go b/internal/shell/tray/icon_linux.go new file mode 100644 index 0000000..bbc183e --- /dev/null +++ b/internal/shell/tray/icon_linux.go @@ -0,0 +1,16 @@ +//go:build linux + +package tray + +import _ "embed" + +//go:embed verstak.png +var defaultIcon []byte + +// DefaultIcon returns the Linux PNG tray icon embedded in the binary. +func DefaultIcon() []byte { + return append([]byte(nil), defaultIcon...) +} + +// IconFileExtension is used when the backend materializes the embedded icon. +func IconFileExtension() string { return ".png" } diff --git a/internal/shell/tray/icon_linux_test.go b/internal/shell/tray/icon_linux_test.go new file mode 100644 index 0000000..aeba076 --- /dev/null +++ b/internal/shell/tray/icon_linux_test.go @@ -0,0 +1,18 @@ +//go:build linux + +package tray + +import "testing" + +func TestDefaultIconUsesPlatformResource(t *testing.T) { + icon := DefaultIcon() + if len(icon) == 0 { + t.Fatal("DefaultIcon() returned no data") + } + if IconFileExtension() != ".png" { + t.Fatalf("IconFileExtension() = %q, want .png on Linux", IconFileExtension()) + } + if string(icon[:8]) != "\x89PNG\r\n\x1a\n" { + t.Fatal("DefaultIcon() is not PNG data on Linux") + } +} diff --git a/internal/shell/tray/icon_windows.go b/internal/shell/tray/icon_windows.go new file mode 100644 index 0000000..6f26f1f --- /dev/null +++ b/internal/shell/tray/icon_windows.go @@ -0,0 +1,16 @@ +//go:build windows + +package tray + +import _ "embed" + +//go:embed verstak.ico +var defaultIcon []byte + +// DefaultIcon returns the multi-resolution Windows ICO embedded in the binary. +func DefaultIcon() []byte { + return append([]byte(nil), defaultIcon...) +} + +// IconFileExtension is used when the backend materializes the embedded icon. +func IconFileExtension() string { return ".ico" } diff --git a/internal/shell/tray/icon_windows_test.go b/internal/shell/tray/icon_windows_test.go new file mode 100644 index 0000000..8f1e39f --- /dev/null +++ b/internal/shell/tray/icon_windows_test.go @@ -0,0 +1,37 @@ +//go:build windows + +package tray + +import ( + "encoding/binary" + "testing" +) + +func TestDefaultIconIsMultiResolutionICO(t *testing.T) { + icon := DefaultIcon() + if IconFileExtension() != ".ico" || len(icon) < 6 { + t.Fatalf("Windows tray icon is not ICO data") + } + if binary.LittleEndian.Uint16(icon[0:2]) != 0 || binary.LittleEndian.Uint16(icon[2:4]) != 1 { + t.Fatal("Windows tray icon has an invalid ICO header") + } + count := int(binary.LittleEndian.Uint16(icon[4:6])) + if count < 6 { + t.Fatalf("ICO image count = %d, want at least 6", count) + } + want := map[int]bool{16: false, 20: false, 24: false, 32: false, 48: false, 256: false} + for offset := 6; offset+16 <= len(icon) && offset < 6+count*16; offset += 16 { + size := int(icon[offset]) + if size == 0 { + size = 256 + } + if _, ok := want[size]; ok { + want[size] = true + } + } + for size, found := range want { + if !found { + t.Errorf("ICO is missing %dx%d image", size, size) + } + } +} diff --git a/internal/shell/tray/systray_backend.go b/internal/shell/tray/systray_backend.go index 834a8a8..a29183e 100644 --- a/internal/shell/tray/systray_backend.go +++ b/internal/shell/tray/systray_backend.go @@ -1,36 +1,141 @@ package tray -import "github.com/getlantern/systray" +import ( + "errors" + "fmt" + "os" + "strings" + "sync" -type systrayBackend struct{} + "fyne.io/systray" +) + +var ( + runWithExternalLoop = systray.RunWithExternalLoop + setOnTapped = systray.SetOnTapped +) + +type systrayBackend struct { + mu sync.Mutex + end func() + iconPath string + started bool + stopRequested bool +} type systrayMenuItem struct { item *systray.MenuItem } -// NewNativeBackend creates the cross-platform native tray backend. +// NewNativeBackend creates the cross-platform tray backend. It uses +// RunWithExternalLoop so the native tray message loop runs alongside Wails. func NewNativeBackend() Backend { - return systrayBackend{} + return &systrayBackend{} } -func (systrayBackend) Register(onReady func(), onExit func()) { - systray.Register(onReady, onExit) +func (b *systrayBackend) Start(callbacks BackendCallbacks) (err error) { + if b == nil { + return errBackendUnavailable + } + b.mu.Lock() + if b.started { + b.mu.Unlock() + return errors.New("native tray backend was already started") + } + b.started = true + b.mu.Unlock() + defer func() { + if recovered := recover(); recovered != nil { + err = fmt.Errorf("native tray startup panic: %v", recovered) + } + }() + start, end := runWithExternalLoop(callbacks.Ready, callbacks.Exit) + if callbacks.LeftClick != nil { + setOnTapped(callbacks.LeftClick) + } + // The Ready callback is asynchronous on Windows and can synchronously + // decide that startup failed. Start the native loop before publishing the + // end function so a concurrent Stop is never left with a half-started loop. + start() + b.mu.Lock() + stopRequested := b.stopRequested + if !stopRequested { + b.end = end + } + b.mu.Unlock() + if stopRequested { + end() + } + return nil } -func (systrayBackend) SetIcon(icon []byte) { - systray.SetIcon(icon) +func (b *systrayBackend) SetIcon(icon []byte) error { + if b == nil || len(icon) == 0 { + return errors.New("tray icon is empty") + } + file, err := os.CreateTemp("", "verstak-tray-*"+IconFileExtension()) + if err != nil { + return fmt.Errorf("create tray icon file: %w", err) + } + path := file.Name() + if _, err := file.Write(icon); err != nil { + file.Close() + os.Remove(path) + return fmt.Errorf("write tray icon file: %w", err) + } + if err := file.Close(); err != nil { + os.Remove(path) + return fmt.Errorf("close tray icon file: %w", err) + } + if err := systray.SetIconFromFilePath(path); err != nil { + os.Remove(path) + return fmt.Errorf("set native tray icon: %w", err) + } + b.mu.Lock() + previous := b.iconPath + b.iconPath = path + b.mu.Unlock() + if previous != "" && previous != path { + _ = os.Remove(previous) + } + return nil } -func (systrayBackend) SetTooltip(tooltip string) { +func (b *systrayBackend) SetTooltip(tooltip string) error { + if strings.TrimSpace(tooltip) == "" { + return errors.New("tray tooltip is empty") + } systray.SetTooltip(tooltip) + return nil } -func (systrayBackend) AddMenuItem(title, tooltip string) MenuItem { - return systrayMenuItem{item: systray.AddMenuItem(title, tooltip)} +func (b *systrayBackend) AddMenuItem(title, tooltip string) (MenuItem, error) { + if strings.TrimSpace(title) == "" { + return nil, errors.New("tray menu title is empty") + } + item := systray.AddMenuItem(title, tooltip) + if item == nil { + return nil, errors.New("native tray menu item is nil") + } + return systrayMenuItem{item: item}, nil } -func (systrayBackend) Quit() { - systray.Quit() +func (b *systrayBackend) Stop() { + if b == nil { + return + } + b.mu.Lock() + end, iconPath := b.end, b.iconPath + b.end = nil + b.iconPath = "" + b.stopRequested = true + b.mu.Unlock() + if end != nil { + end() + } + if iconPath != "" { + _ = os.Remove(iconPath) + } } func (item systrayMenuItem) Clicked() <-chan struct{} { diff --git a/internal/shell/tray/systray_backend_test.go b/internal/shell/tray/systray_backend_test.go new file mode 100644 index 0000000..b3a8c64 --- /dev/null +++ b/internal/shell/tray/systray_backend_test.go @@ -0,0 +1,30 @@ +package tray + +import "testing" + +func TestBackendStopsWhenReadyCallbackStopsDuringStartup(t *testing.T) { + originalRun := runWithExternalLoop + originalSetOnTapped := setOnTapped + defer func() { + runWithExternalLoop = originalRun + setOnTapped = originalSetOnTapped + }() + + var startCalls, endCalls int + runWithExternalLoop = func(onReady, _ func()) (func(), func()) { + onReady() + return func() { startCalls++ }, func() { endCalls++ } + } + setOnTapped = func(func()) {} + + backend := &systrayBackend{} + if err := backend.Start(BackendCallbacks{Ready: backend.Stop}); err != nil { + t.Fatalf("Start: %v", err) + } + if startCalls != 1 { + t.Fatalf("start calls = %d, want 1", startCalls) + } + if endCalls != 1 { + t.Fatalf("end calls = %d, want 1", endCalls) + } +} diff --git a/internal/shell/tray/verstak.ico b/internal/shell/tray/verstak.ico new file mode 100644 index 0000000..70a0080 Binary files /dev/null and b/internal/shell/tray/verstak.ico differ diff --git a/internal/shell/tray/verstak.png b/internal/shell/tray/verstak.png new file mode 100644 index 0000000..5be9e8a Binary files /dev/null and b/internal/shell/tray/verstak.png differ diff --git a/main.go b/main.go index 4a77f54..d92ecc9 100644 --- a/main.go +++ b/main.go @@ -230,6 +230,7 @@ func main() { app = api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, filesService, appSettingsMgr, pluginStateMgr, workspaceMgr, syncService, browserReceiver, debugEnabled) app.SetNotificationService(notifications.New(vaultService, api.NewNativeNotificationSender(), time.Now)) trayController := tray.New(tray.NewNativeBackend(), tray.DefaultIcon()) + trayController.SetReadyChangedHandler(app.SetTrayReady) trayLabels := func(language string) tray.Labels { return tray.LabelsForPreference(language, os.Getenv("LC_ALL"), os.Getenv("LC_MESSAGES"), os.Getenv("LANG")) } @@ -258,7 +259,9 @@ func main() { OnStartup: app.Startup, OnDomReady: func(ctx context.Context) { app.DomReady(ctx) - trayController.Start(tray.Actions{Show: app.ShowWindow, Quit: app.Quit}) + if err := trayController.Start(tray.Actions{Show: app.ShowWindow, Quit: app.Quit}); err != nil { + log.Printf("[tray] disabled: %v; normal window close will exit", err) + } }, OnShutdown: func(ctx context.Context) { trayController.Stop() diff --git a/packaging/deb/control b/packaging/deb/control index 4a60432..92c096a 100644 --- a/packaging/deb/control +++ b/packaging/deb/control @@ -3,7 +3,7 @@ Version: @VERSION@ Section: utils Priority: optional Architecture: amd64 -Depends: libwebkit2gtk-4.1-0, libgtk-3-0t64 | libgtk-3-0, libayatana-appindicator3-1 +Depends: libwebkit2gtk-4.1-0, libgtk-3-0t64 | libgtk-3-0 Maintainer: Verstak contributors Homepage: https://github.com/mirivlad/verstak Description: Local-first workspace diff --git a/release-notes/v0.1.0-alpha.5.md b/release-notes/v0.1.0-alpha.5.md new file mode 100644 index 0000000..6106d38 --- /dev/null +++ b/release-notes/v0.1.0-alpha.5.md @@ -0,0 +1,31 @@ +## Highlights + +- Fixed the Windows 10 system-tray integration: the release now embeds a real + multi-resolution Windows icon and runs the native tray message loop. +- One left click on the tray icon restores and focuses Verstak. A right click + opens the native, localized **Show Verstak** and **Quit** menu. +- Closing the window now hides it only after the tray is confirmed ready. If + the tray cannot start, the app exits normally instead of becoming unreachable. +- Replaced the obsolete tray dependency with `fyne.io/systray`, removing the + AppIndicator requirement from Linux builds, Debian packages, and AppImages. + +## Главное + +- Исправлена интеграция с системным треем в Windows 10: релиз содержит + настоящий многомасштабный значок Windows и запускает нативный цикл сообщений + трея. +- Один левый клик по значку возвращает и фокусирует Верстак. Правый клик + открывает нативное локализованное меню **«Показать Верстак»** и **«Выйти»**. +- Окно скрывается только после подтверждённой готовности трея. Если трей не + запускается, приложение завершается обычным образом и не остаётся + недоступным. +- Устаревшая зависимость трея заменена на `fyne.io/systray`; AppIndicator + больше не нужен для Linux-сборки, Debian-пакета и AppImage. + +## Packages / Пакеты + +This prerelease provides a portable Windows archive, a Debian package, and an +AppImage for Linux. + +В этот prerelease входят переносимый архив для Windows, Debian-пакет и AppImage +для Linux. diff --git a/scripts/build.sh b/scripts/build.sh index 5956b35..715e410 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -26,15 +26,6 @@ if ! command -v npm &>/dev/null; then fi echo " ✅ npm $(npm --version)" -if [[ "$(go env GOOS)" == "linux" ]]; then - if ! command -v pkg-config &>/dev/null || ! pkg-config --exists ayatana-appindicator3-0.1; then - echo " ❌ native tray development files are missing" - echo " Debian/Ubuntu: sudo apt install libayatana-appindicator3-dev" - exit 1 - fi - echo " ✅ ayatana-appindicator3 $(pkg-config --modversion ayatana-appindicator3-0.1)" -fi - # ── Frontend (build first — Go //go:embed needs frontend/dist/) ── echo "" echo "[frontend]" diff --git a/scripts/package-appimage.sh b/scripts/package-appimage.sh index 6953f98..0371d7e 100755 --- a/scripts/package-appimage.sh +++ b/scripts/package-appimage.sh @@ -106,11 +106,6 @@ while IFS= read -r candidate; do done < "$APPDIR/.elf-queue" rm -f "$APPDIR/.elf-queue" -if ! compgen -G "$APPDIR/usr/lib/libayatana-appindicator3.so.*" >/dev/null; then - echo "native tray runtime library was not bundled" >&2 - exit 1 -fi - if command -v glib-compile-schemas >/dev/null; then glib-compile-schemas "$APPDIR/usr/share/glib-2.0/schemas" fi diff --git a/scripts/test-package-formats.sh b/scripts/test-package-formats.sh index 4863e02..45b72f3 100755 --- a/scripts/test-package-formats.sh +++ b/scripts/test-package-formats.sh @@ -26,11 +26,16 @@ grep -Fq 'gtk-update-icon-cache' "$ROOT/packaging/deb/postinst" grep -Fq 'packaging/linux/verstak' "$ROOT/scripts/package-appimage.sh" grep -Fq 'usr/bin/verstak' "$ROOT/packaging/linux/AppRun" grep -Fq 'libwebkit2gtk-4.1-0' "$ROOT/packaging/deb/control" -grep -Fq 'libayatana-appindicator3-1' "$ROOT/packaging/deb/control" +if grep -Fq 'appindicator' "$ROOT/packaging/deb/control"; then + echo "Debian package must not require the removed AppIndicator tray backend" >&2 + exit 1 +fi grep -Fq 'appimagetool' "$ROOT/scripts/package-appimage.sh" grep -Fq 'WebKitWebProcess' "$ROOT/scripts/package-appimage.sh" -grep -Fq 'libayatana-appindicator3.so.' "$ROOT/scripts/package-appimage.sh" -grep -Fq 'ayatana-appindicator3-0.1' "$ROOT/scripts/build.sh" +if grep -Fq 'appindicator' "$ROOT/scripts/package-appimage.sh" "$ROOT/scripts/build.sh"; then + echo "Linux build and AppImage scripts must not require AppIndicator" >&2 + exit 1 +fi if grep -Fq 'FixedVersionRuntime' "$ROOT/scripts/package-windows-portable.sh"; then echo "Windows portable archive must not bundle Fixed Version WebView2" >&2 exit 1