From 6eeebd39ad29377eb10c6b396ea1b18d58d6e008 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Wed, 15 Jul 2026 18:56:22 +0800 Subject: [PATCH] fix: restore Windows tray lifecycle --- README.md | 3 +- README.ru.md | 3 +- ...026-07-14-native-notifications-and-tray.md | 21 +- ...14-native-notifications-and-tray-design.md | 60 ++--- go.mod | 10 +- go.sum | 28 +-- internal/api/app.go | 27 ++- internal/api/app_test.go | 37 ++- internal/shell/tray/controller.go | 217 +++++++++++++++--- internal/shell/tray/controller_test.go | 183 +++++++++++---- internal/shell/tray/icon.go | 14 -- internal/shell/tray/icon_linux.go | 16 ++ internal/shell/tray/icon_linux_test.go | 18 ++ internal/shell/tray/icon_windows.go | 16 ++ internal/shell/tray/icon_windows_test.go | 37 +++ internal/shell/tray/systray_backend.go | 131 +++++++++-- internal/shell/tray/systray_backend_test.go | 30 +++ internal/shell/tray/verstak.ico | Bin 0 -> 27804 bytes internal/shell/tray/verstak.png | Bin 0 -> 19092 bytes main.go | 5 +- packaging/deb/control | 2 +- release-notes/v0.1.0-alpha.5.md | 31 +++ scripts/build.sh | 9 - scripts/package-appimage.sh | 5 - scripts/test-package-formats.sh | 11 +- 25 files changed, 711 insertions(+), 203 deletions(-) delete mode 100644 internal/shell/tray/icon.go create mode 100644 internal/shell/tray/icon_linux.go create mode 100644 internal/shell/tray/icon_linux_test.go create mode 100644 internal/shell/tray/icon_windows.go create mode 100644 internal/shell/tray/icon_windows_test.go create mode 100644 internal/shell/tray/systray_backend_test.go create mode 100644 internal/shell/tray/verstak.ico create mode 100644 internal/shell/tray/verstak.png create mode 100644 release-notes/v0.1.0-alpha.5.md 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 0000000000000000000000000000000000000000..70a0080ba3270ea6efc1e09049289d0d9c1f6ce1 GIT binary patch literal 27804 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 F{{W8m9xDI< literal 0 HcmV?d00001 diff --git a/internal/shell/tray/verstak.png b/internal/shell/tray/verstak.png new file mode 100644 index 0000000000000000000000000000000000000000..5be9e8a7a886ab37e96e51d93bcfd65be8378cfa GIT binary patch literal 19092 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
    ;`d&5VX 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 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