fix: restore Windows tray lifecycle

This commit is contained in:
mirivlad 2026-07-15 18:56:22 +08:00
parent 3c1f2c7177
commit 6eeebd39ad
25 changed files with 711 additions and 203 deletions

View File

@ -124,7 +124,7 @@ Verstak uses the Microsoft WebView2 Runtime. It is already included with Windows
## Background and tray mode ## 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 ### Verify a download
@ -234,7 +234,6 @@ Each vault is connected separately. The local vault remains the primary copy of
* Git; * Git;
* Wails v2 build dependencies; * Wails v2 build dependencies;
* WebKitGTK development packages on Linux. * WebKitGTK development packages on Linux.
* Ayatana AppIndicator development files on Linux (`sudo apt install libayatana-appindicator3-dev`).
See the See the
[Wails installation documentation](https://wails.io/docs/gettingstarted/installation/) [Wails installation documentation](https://wails.io/docs/gettingstarted/installation/)

View File

@ -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; * Git;
* зависимости для сборки Wails v2; * зависимости для сборки Wails v2;
* пакеты разработки WebKitGTK в Linux. * пакеты разработки WebKitGTK в Linux.
* файлы разработки Ayatana AppIndicator в Linux (`sudo apt install libayatana-appindicator3-dev`).
Зависимости для конкретного дистрибутива перечислены в Зависимости для конкретного дистрибутива перечислены в
[документации Wails](https://wails.io/docs/gettingstarted/installation/). [документации Wails](https://wails.io/docs/gettingstarted/installation/).

View File

@ -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. **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 ## Global constraints
- Support Windows and Linux only; no background daemon after an explicit Quit. - 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`. - `verstak.todo` requires `verstak/core/notifications/v1` and `notifications.schedule`.
- Plugins cannot receive Wails runtime access. - Plugins cannot receive Wails runtime access.
- Keep Wails generated bindings out of commits. - Keep Wails generated bindings out of commits.
@ -231,7 +232,12 @@ Expected: absent packages/methods.
- [ ] **Step 3: Implement adapter and Wails wiring** - [ ] **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** - [ ] **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 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 GOCACHE=/tmp/verstak-go-cache go test ./internal/shell/tray ./internal/api -count=1
./scripts/build.sh ./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 git diff --check
``` ```
@ -265,7 +271,8 @@ git push mirror main
- [ ] **Step 1: Add failing package-contract checks** - [ ] **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** - [ ] **Step 2: Confirm red**
@ -275,7 +282,9 @@ Expected: the first tray dependency assertion fails.
- [ ] **Step 3: Implement portable package support** - [ ] **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** - [ ] **Step 4: Install the supplied public README sources**

View File

@ -1,6 +1,6 @@
# Native Notifications and System Tray Design # 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 ## Goal
@ -19,27 +19,33 @@ must work in the portable Windows archive, Debian package, and AppImage.
## Tray behavior ## Tray behavior
On Windows and Linux, a tray icon is registered before the Wails event loop On Windows and Linux, a tray icon is initialized after Wails reaches
starts. Its menu contains exactly two actions: `OnDomReady`. Its menu contains exactly two actions:
1. **Show Verstak** — shows and focuses the existing main window. 1. **Show Verstak** — shows and focuses the existing main window.
2. **Quit** — exits the process deliberately. 2. **Quit** — exits the process deliberately.
Closing the main window with its window-manager close control hides the window One left click restores and focuses the existing main window. A native right
and keeps the process, plugins, local browser receiver, and reminder scheduler click opens the menu. Closing the main window with its window-manager close
alive. It does not terminate the application. The quit action temporarily control hides the window only after the tray has successfully initialized; it
allows the close lifecycle to finish and then exits normally. 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 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 instance is hidden in the tray, the existing instance shows its window instead
of creating a second process. of creating a second process.
The implementation uses `github.com/getlantern/systray` through a small The implementation uses `fyne.io/systray` through a small
`internal/shell/tray` adapter. It uses `Register`, rather than its blocking `internal/shell/tray` adapter. `RunWithExternalLoop` starts the Windows native
`Run`, so Wails remains the owner of the GUI event loop. A compact PNG derived message loop without making Wails relinquish ownership of its GUI lifecycle.
from the tracked project logo is encoded in the tray package, so a clean build The icon is a source-controlled multi-resolution ICO on Windows (16, 20, 24,
does not depend on ignored Wails-generated icon files. The library accepts PNG 32, 48, and 256 pixels with transparency) and a PNG on Linux. Both are embedded
icon bytes on both target platforms. 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 ## Notification capability and permission
@ -116,16 +122,14 @@ is not removed.
## Packaging ## Packaging
`getlantern/systray` requires CGO. The Windows release build already uses `fyne.io/systray` supplies the Windows message loop and uses the session D-Bus
`x86_64-w64-mingw32-gcc`; the Windows packaging tests must compile it with the on Linux. It does not require the removed AppIndicator development or runtime
tray dependency included. Linux build instructions add package. The Windows release build still uses `x86_64-w64-mingw32-gcc` for the
`libayatana-appindicator3-dev`. The Debian package declares the corresponding Wails application itself.
runtime dependency `libayatana-appindicator3-1`.
The existing AppImage packager traverses `ldd` for the desktop executable and The existing AppImage packager traverses `ldd` for the desktop executable and
copies non-glibc runtime libraries. Its verification is extended to prove that copies non-glibc runtime libraries; it does not require a tray-specific shared
the appindicator library is present in the AppDir when the tray implementation library.
is compiled in.
## Public README and product screenshots ## Public README and product screenshots
@ -156,15 +160,19 @@ Automated tests cover:
- schedule replacement, cancellation, rescheduling, persistence, one-time - schedule replacement, cancellation, rescheduling, persistence, one-time
overdue delivery, failed-send retry, and permission/capability rejection; overdue delivery, failed-send retry, and permission/capability rejection;
- Todo desired-list derivation and calls after create/edit/status/delete; - Todo desired-list derivation and calls after create/edit/status/delete;
- close policy: ordinary close hides, explicit quit permits shutdown; - close policy: ordinary close hides only after tray readiness, while explicit
- tray controller action wiring and second-instance window reveal; quit permits shutdown;
- Linux/Windows build scripts and package dependency expectations. - 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 Manual smoke tests are required because neither unit tests nor Playwright can
assert a real desktop notification area or OS toast: assert a real desktop notification area or OS toast:
1. On Linux and Windows, start Verstak, close its window, use the tray menu to 1. On Linux and Windows, start Verstak, verify the tray icon and tooltip, use
reveal it, and use **Quit** to terminate it. 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 2. Set a Todo reminder for a near future time, hide the window in the tray, and
observe one native notification. observe one native notification.
3. Quit before a future reminder, relaunch after it expires, and observe one 3. Quit before a future reminder, relaunch after it expires, and observe one

10
go.mod
View File

@ -3,7 +3,7 @@ module github.com/verstak/verstak-desktop
go 1.24.4 go 1.24.4
require ( require (
github.com/getlantern/systray v1.2.2 fyne.io/systray v1.12.2
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/wailsapp/wails/v2 v2.12.0 github.com/wailsapp/wails/v2 v2.12.0
golang.org/x/net v0.35.0 golang.org/x/net v0.35.0
@ -12,14 +12,7 @@ require (
require ( require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/bep/debounce v1.2.1 // 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-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/godbus/dbus/v5 v5.1.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // 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/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // 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/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect

28
go.sum
View File

@ -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 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= 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 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= 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 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 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/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 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= 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.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 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= 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.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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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 h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 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/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 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= 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 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= github.com/tkrajina/go-reflector v0.5.8 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 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= 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-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-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-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.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 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= 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= 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -11,6 +11,7 @@ import (
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
"sync"
"sync/atomic" "sync/atomic"
"time" "time"
@ -100,6 +101,8 @@ type App struct {
browserInboxEvents map[string]bool browserInboxEvents map[string]bool
browserInboxEnabled atomic.Bool browserInboxEnabled atomic.Bool
allowQuit atomic.Bool allowQuit atomic.Bool
trayReady atomic.Bool
quitOnce sync.Once
} }
// SetNotificationService attaches the core-owned plugin notification scheduler. // SetNotificationService attaches the core-owned plugin notification scheduler.
@ -221,11 +224,23 @@ func (a *App) Shutdown(ctx context.Context) {
cleanupNativeNotifications(ctx) 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 { func (a *App) BeforeClose(ctx context.Context) bool {
if a.allowQuit.Load() { if a.allowQuit.Load() {
return false return false
} }
if !a.trayReady.Load() {
log.Printf("[app] tray is unavailable; allowing normal window close")
return false
}
hideNativeWindow(ctx) hideNativeWindow(ctx)
return true return true
} }
@ -243,10 +258,12 @@ func (a *App) Quit() {
if a == nil { if a == nil {
return return
} }
a.allowQuit.Store(true) a.quitOnce.Do(func() {
if a.ctx != nil { a.allowQuit.Store(true)
quitNativeApplication(a.ctx) if a.ctx != nil {
} quitNativeApplication(a.ctx)
}
})
} }
// NativeNotificationSender delivers scheduler items through the Wails runtime. // NativeNotificationSender delivers scheduler items through the Wails runtime.

View File

@ -319,7 +319,7 @@ func TestNativeNotificationSenderUsesStablePluginScopedID(t *testing.T) {
} }
} }
func TestBeforeCloseHidesWindowUntilUserChoosesQuit(t *testing.T) { func TestBeforeCloseAllowsExitUntilTrayIsReady(t *testing.T) {
oldHide := hideNativeWindow oldHide := hideNativeWindow
defer func() { hideNativeWindow = oldHide }() defer func() { hideNativeWindow = oldHide }()
@ -327,8 +327,25 @@ func TestBeforeCloseHidesWindowUntilUserChoosesQuit(t *testing.T) {
hideNativeWindow = func(context.Context) { hideCalls++ } hideNativeWindow = func(context.Context) { hideCalls++ }
app := &App{} 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 { 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 { if hideCalls != 1 {
t.Fatalf("hide calls = %d, want 1", hideCalls) 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) { func TestShowWindowUsesWailsContext(t *testing.T) {
oldShow := showNativeWindow oldShow := showNativeWindow
defer func() { showNativeWindow = oldShow }() defer func() { showNativeWindow = oldShow }()

View File

@ -2,10 +2,15 @@
package tray package tray
import ( import (
"errors"
"log"
"strings" "strings"
"sync" "sync"
"sync/atomic"
) )
var errBackendUnavailable = errors.New("tray backend is unavailable")
// MenuItem exposes click events from a native tray menu item. // MenuItem exposes click events from a native tray menu item.
type MenuItem interface { type MenuItem interface {
Clicked() <-chan struct{} Clicked() <-chan struct{}
@ -13,13 +18,22 @@ type MenuItem interface {
SetTooltip(tooltip string) 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. // Backend is the platform tray implementation.
type Backend interface { type Backend interface {
Register(onReady func(), onExit func()) Start(callbacks BackendCallbacks) error
SetIcon(icon []byte) SetIcon(icon []byte) error
SetTooltip(tooltip string) SetTooltip(tooltip string) error
AddMenuItem(title, tooltip string) MenuItem AddMenuItem(title, tooltip string) (MenuItem, error)
Quit() Stop()
} }
// Actions are executed from the native tray menu. // Actions are executed from the native tray menu.
@ -80,11 +94,20 @@ func LabelsForPreference(preference string, systemLocales ...string) Labels {
type Controller struct { type Controller struct {
backend Backend backend Backend
icon []byte icon []byte
start sync.Once
mu sync.RWMutex startOnce sync.Once
labels Labels readyOnce sync.Once
show MenuItem stopOnce sync.Once
quit MenuItem 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. // 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. // SetLabels updates the current and future native tray menu labels.
func (c *Controller) SetLabels(labels Labels) { func (c *Controller) SetLabels(labels Labels) {
if c == nil { if c == nil {
@ -108,35 +148,142 @@ func (c *Controller) SetLabels(labels Labels) {
applyLabels(show, quit, labels) applyLabels(show, quit, labels)
} }
// Start registers the native tray without taking over the Wails event loop. // Start connects the tray to Wails without taking over Wails' GUI event loop.
func (c *Controller) Start(actions Actions) { // 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 { 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 return
} }
c.start.Do(func() { if !c.started.Load() {
c.backend.Register(func() { log.Printf("[tray] ready callback ignored after backend startup failed")
c.backend.SetIcon(c.icon) return
c.backend.SetTooltip("Verstak") }
c.mu.RLock() c.readyOnce.Do(func() {
labels := c.labels log.Printf("[tray] native backend reported ready")
c.mu.RUnlock() if err := c.backend.SetIcon(c.icon); err != nil {
show := c.backend.AddMenuItem(labels.ShowTitle, labels.ShowTooltip) c.fail("icon setup", err)
quit := c.backend.AddMenuItem(labels.QuitTitle, labels.QuitTooltip) return
c.mu.Lock() }
c.show, c.quit = show, quit if err := c.backend.SetTooltip("Verstak"); err != nil {
labels = c.labels c.fail("tooltip setup", err)
c.mu.Unlock() return
applyLabels(show, quit, labels) }
if actions.Show != nil && show != nil { c.mu.RLock()
go routeClicks(show.Clicked(), actions.Show) 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 { c.fail("show menu creation", err)
go routeClicks(quit.Clicked(), actions.Quit) 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) { func applyLabels(show, quit MenuItem, labels Labels) {
if show != nil { if show != nil {
show.SetTitle(labels.ShowTitle) 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. // Stop releases the native tray after Wails has begun application shutdown.
func (c *Controller) Stop() { func (c *Controller) Stop() {
if c == nil || c.backend == nil { if c == nil || c.backend == nil || !c.started.Load() {
return 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()) { func routeClicks(clicked <-chan struct{}, action func()) {

View File

@ -1,6 +1,8 @@
package tray package tray
import ( import (
"errors"
"sync"
"testing" "testing"
"time" "time"
) )
@ -11,47 +13,65 @@ type fakeMenuItem struct {
tooltip string tooltip string
} }
func (i *fakeMenuItem) Clicked() <-chan struct{} { func (i *fakeMenuItem) Clicked() <-chan struct{} { return i.clicked }
return i.clicked func (i *fakeMenuItem) SetTitle(title string) { i.title = title }
} func (i *fakeMenuItem) SetTooltip(tooltip string) { i.tooltip = tooltip }
func (i *fakeMenuItem) SetTitle(title string) {
i.title = title
}
func (i *fakeMenuItem) SetTooltip(tooltip string) {
i.tooltip = tooltip
}
type fakeBackend struct { type fakeBackend struct {
icon []byte callbacks BackendCallbacks
tooltip string icon []byte
items map[string]*fakeMenuItem tooltip string
quitCalls int items map[string]*fakeMenuItem
registering bool startErr error
iconErr error
menuErr error
starts int
stops int
} }
func (b *fakeBackend) Register(onReady func(), _ func()) { func (b *fakeBackend) Start(callbacks BackendCallbacks) error {
b.registering = true b.starts++
onReady() 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...) 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 b.tooltip = tooltip
return nil
} }
func (b *fakeBackend) AddMenuItem(title, _ string) MenuItem { func (b *fakeBackend) AddMenuItem(title, tooltip string) (MenuItem, error) {
item := &fakeMenuItem{clicked: make(chan struct{}, 1), title: title} if b.menuErr != nil {
return nil, b.menuErr
}
item := &fakeMenuItem{clicked: make(chan struct{}, 1), title: title, tooltip: tooltip}
b.items[title] = item b.items[title] = item
return item return item, nil
} }
func (b *fakeBackend) Quit() { func (b *fakeBackend) Stop() { b.stops++ }
b.quitCalls++
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{}) { 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)} 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 := 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{}{} }, Show: func() { showCalls <- struct{}{} },
Quit: func() { quitCalls <- struct{}{} }, Quit: func() { quitCalls <- struct{}{} },
}) }); err != nil {
t.Fatalf("Start() error = %v", err)
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)
} }
backend.ready()
backend.leftClick()
waitFor(t, showCalls)
showItem := backend.items["Show Verstak"] showItem := backend.items["Show Verstak"]
quitItem := backend.items["Quit"] quitItem := backend.items["Quit"]
if showItem == nil || quitItem == nil { if showItem == nil || quitItem == nil {
t.Fatalf("tray menu = %#v, want Show Verstak and Quit", backend.items) t.Fatalf("tray menu = %#v, want Show Verstak and Quit", backend.items)
} }
showItem.clicked <- struct{}{} showItem.clicked <- struct{}{}
waitFor(t, showCalls) waitFor(t, showCalls)
quitItem.clicked <- struct{}{} quitItem.clicked <- struct{}{}
waitFor(t, quitCalls) waitFor(t, quitCalls)
} }
func TestControllerStopsNativeTrayBackend(t *testing.T) { func TestControllerStopCallsBackendOnce(t *testing.T) {
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)} backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
controller := New(backend, []byte{1}) controller := New(backend, []byte{1})
if err := controller.Start(Actions{}); err != nil {
t.Fatalf("Start() error = %v", err)
}
controller.Stop()
controller.Stop() controller.Stop()
if backend.quitCalls != 1 { if backend.stops != 1 {
t.Fatalf("backend quit calls = %d, want 1", backend.quitCalls) 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)} backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
controller := New(backend, []byte{1}) controller := New(backend, []byte{1})
controller.SetLabels(LabelsForPreference("ru")) 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["Показать Верстак"] show := backend.items["Показать Верстак"]
quit := backend.items["Выйти"] quit := backend.items["Выйти"]
if show == nil || quit == nil { if show == nil || quit == nil {
t.Fatalf("Russian tray menu = %#v", backend.items) 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")) controller.SetLabels(LabelsForPreference("en"))
if show.title != "Show Verstak" || quit.title != "Quit" { if show.title != "Show Verstak" || quit.title != "Quit" {
t.Fatalf("English tray menu after update = show:%q quit:%q", show.title, quit.title) 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" { backend.exited()
t.Fatalf("English tray tooltips after update = show:%q quit:%q", show.tooltip, quit.tooltip) 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) 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)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,36 +1,141 @@
package tray 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 { type systrayMenuItem struct {
item *systray.MenuItem 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 { func NewNativeBackend() Backend {
return systrayBackend{} return &systrayBackend{}
} }
func (systrayBackend) Register(onReady func(), onExit func()) { func (b *systrayBackend) Start(callbacks BackendCallbacks) (err error) {
systray.Register(onReady, onExit) 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) { func (b *systrayBackend) SetIcon(icon []byte) error {
systray.SetIcon(icon) 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) systray.SetTooltip(tooltip)
return nil
} }
func (systrayBackend) AddMenuItem(title, tooltip string) MenuItem { func (b *systrayBackend) AddMenuItem(title, tooltip string) (MenuItem, error) {
return systrayMenuItem{item: systray.AddMenuItem(title, tooltip)} 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() { func (b *systrayBackend) Stop() {
systray.Quit() 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{} { func (item systrayMenuItem) Clicked() <-chan struct{} {

View File

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

View File

@ -230,6 +230,7 @@ func main() {
app = api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, filesService, appSettingsMgr, pluginStateMgr, workspaceMgr, syncService, browserReceiver, debugEnabled) 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)) app.SetNotificationService(notifications.New(vaultService, api.NewNativeNotificationSender(), time.Now))
trayController := tray.New(tray.NewNativeBackend(), tray.DefaultIcon()) trayController := tray.New(tray.NewNativeBackend(), tray.DefaultIcon())
trayController.SetReadyChangedHandler(app.SetTrayReady)
trayLabels := func(language string) tray.Labels { trayLabels := func(language string) tray.Labels {
return tray.LabelsForPreference(language, os.Getenv("LC_ALL"), os.Getenv("LC_MESSAGES"), os.Getenv("LANG")) return tray.LabelsForPreference(language, os.Getenv("LC_ALL"), os.Getenv("LC_MESSAGES"), os.Getenv("LANG"))
} }
@ -258,7 +259,9 @@ func main() {
OnStartup: app.Startup, OnStartup: app.Startup,
OnDomReady: func(ctx context.Context) { OnDomReady: func(ctx context.Context) {
app.DomReady(ctx) 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) { OnShutdown: func(ctx context.Context) {
trayController.Stop() trayController.Stop()

View File

@ -3,7 +3,7 @@ Version: @VERSION@
Section: utils Section: utils
Priority: optional Priority: optional
Architecture: amd64 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> Maintainer: Verstak contributors <dev@verstak.app>
Homepage: https://github.com/mirivlad/verstak Homepage: https://github.com/mirivlad/verstak
Description: Local-first workspace Description: Local-first workspace

View File

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

View File

@ -26,15 +26,6 @@ if ! command -v npm &>/dev/null; then
fi fi
echo " ✅ npm $(npm --version)" 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/) ── # ── Frontend (build first — Go //go:embed needs frontend/dist/) ──
echo "" echo ""
echo "[frontend]" echo "[frontend]"

View File

@ -106,11 +106,6 @@ while IFS= read -r candidate; do
done < "$APPDIR/.elf-queue" done < "$APPDIR/.elf-queue"
rm -f "$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 if command -v glib-compile-schemas >/dev/null; then
glib-compile-schemas "$APPDIR/usr/share/glib-2.0/schemas" glib-compile-schemas "$APPDIR/usr/share/glib-2.0/schemas"
fi fi

View File

@ -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 'packaging/linux/verstak' "$ROOT/scripts/package-appimage.sh"
grep -Fq 'usr/bin/verstak' "$ROOT/packaging/linux/AppRun" grep -Fq 'usr/bin/verstak' "$ROOT/packaging/linux/AppRun"
grep -Fq 'libwebkit2gtk-4.1-0' "$ROOT/packaging/deb/control" 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 'appimagetool' "$ROOT/scripts/package-appimage.sh"
grep -Fq 'WebKitWebProcess' "$ROOT/scripts/package-appimage.sh" grep -Fq 'WebKitWebProcess' "$ROOT/scripts/package-appimage.sh"
grep -Fq 'libayatana-appindicator3.so.' "$ROOT/scripts/package-appimage.sh" if grep -Fq 'appindicator' "$ROOT/scripts/package-appimage.sh" "$ROOT/scripts/build.sh"; then
grep -Fq 'ayatana-appindicator3-0.1' "$ROOT/scripts/build.sh" 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 if grep -Fq 'FixedVersionRuntime' "$ROOT/scripts/package-windows-portable.sh"; then
echo "Windows portable archive must not bundle Fixed Version WebView2" >&2 echo "Windows portable archive must not bundle Fixed Version WebView2" >&2
exit 1 exit 1