fix: restore Windows tray lifecycle
This commit is contained in:
parent
3c1f2c7177
commit
6eeebd39ad
|
|
@ -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/)
|
||||
|
|
|
|||
|
|
@ -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/).
|
||||
|
|
|
|||
|
|
@ -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**
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
10
go.mod
10
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
|
||||
|
|
|
|||
28
go.sum
28
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=
|
||||
|
|
|
|||
|
|
@ -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.quitOnce.Do(func() {
|
||||
a.allowQuit.Store(true)
|
||||
if a.ctx != nil {
|
||||
quitNativeApplication(a.ctx)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// NativeNotificationSender delivers scheduler items through the Wails runtime.
|
||||
|
|
|
|||
|
|
@ -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 }()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
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,34 +148,141 @@ 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
|
||||
}
|
||||
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.start.Do(func() {
|
||||
c.backend.Register(func() {
|
||||
c.backend.SetIcon(c.icon)
|
||||
c.backend.SetTooltip("Verstak")
|
||||
c.mu.RLock()
|
||||
labels := c.labels
|
||||
actions := c.actions
|
||||
c.mu.RUnlock()
|
||||
show := c.backend.AddMenuItem(labels.ShowTitle, labels.ShowTooltip)
|
||||
quit := c.backend.AddMenuItem(labels.QuitTitle, labels.QuitTooltip)
|
||||
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")
|
||||
}
|
||||
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")
|
||||
}
|
||||
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 && show != nil {
|
||||
go routeClicks(show.Clicked(), actions.Show)
|
||||
}
|
||||
if actions.Quit != nil && quit != nil {
|
||||
go routeClicks(quit.Clicked(), actions.Quit)
|
||||
}
|
||||
}, nil)
|
||||
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 {
|
||||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
callbacks BackendCallbacks
|
||||
icon []byte
|
||||
tooltip string
|
||||
items map[string]*fakeMenuItem
|
||||
quitCalls int
|
||||
registering bool
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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" }
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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" }
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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{} {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
5
main.go
5
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()
|
||||
|
|
|
|||
|
|
@ -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 <dev@verstak.app>
|
||||
Homepage: https://github.com/mirivlad/verstak
|
||||
Description: Local-first workspace
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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]"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue