40 changed files with 1060 additions and 188 deletions
+2 -2
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
@@ -232,9 +232,9 @@ Each vault is connected separately. The local vault remains the primary copy of
* Node.js 20 or newer with npm; * Node.js 20 or newer with npm;
* Python 3; * Python 3;
* Git; * Git;
* ImageMagick (`magick`) to generate the branded tray and application icons;
* 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/)
+3 -3
View File
@@ -7,7 +7,7 @@
### Рабочий контекст остаётся рядом — и хранится локально. ### Рабочий контекст остаётся рядом — и хранится локально.
Файлы, заметки, ссылки, материалы из браузера, активность и история работы Файлы, заметки, ссылки, материалы из браузера, активность и история работы
в одном расширяемом рабочем пространстве. в одной расширяемой локальной среде.
[English](README.md) · **Русский** [English](README.md) · **Русский**
@@ -124,7 +124,7 @@ APPIMAGE_EXTRACT_AND_RUN=1 ./verstak-linux-x86_64-*.AppImage
## Фоновая работа и трей ## Фоновая работа и трей
Закрытие главного окна не завершает Верстак: приложение остаётся в системном трее. Выберите **Show Verstak**, чтобы вернуть окно, или **Quit**, чтобы полностью завершить приложение. Благодаря этому напоминания Todo продолжают работать, пока окно скрыто. После готовности значка в трее закрытие главного окна не завершает Верстак. Один левый клик возвращает и фокусирует окно, правый клик открывает нативное меню **«Показать Верстак»** и **«Выйти»**. **«Выйти»** полностью завершает приложение. Если трей не удалось запустить, закрытие окна завершает приложение обычным образом и не оставляет недоступный процесс. Пока окно скрыто, напоминания Todo продолжают работать.
### Проверка скачанного файла ### Проверка скачанного файла
@@ -232,9 +232,9 @@ sha256sum -c SHA256SUMS --ignore-missing
* Node.js 20 или новее и npm; * Node.js 20 или новее и npm;
* Python 3; * Python 3;
* Git; * Git;
* ImageMagick (`magick`) для генерации фирменных значков трея и приложения;
* зависимости для сборки 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/).
@@ -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**
@@ -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
+5
View File
@@ -25,6 +25,11 @@ test.describe('Desktop localization', () => {
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Тест платформы' })).toBeVisible(); await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Тест платформы' })).toBeVisible();
await expect(page.locator('[data-status-item-id="verstak.platform-test.status"]')).toContainText('Все тесты пройдены'); await expect(page.locator('[data-status-item-id="verstak.platform-test.status"]')).toContainText('Все тесты пройдены');
const project = page.locator('.wt-node').filter({ hasText: 'Project' });
await project.locator('button[title="Переименовать Дело"]').click();
await expect(page.locator('button[title="Сохранить новое название"]')).toHaveText('Сохранить');
await page.locator('.wt-rename').press('Escape');
await page.locator('[data-settings-menu-button]').click(); await page.locator('[data-settings-menu-button]').click();
await expect(page.locator('[data-settings-action="plugin-manager"]')).toContainText('Менеджер плагинов'); await expect(page.locator('[data-settings-action="plugin-manager"]')).toContainText('Менеджер плагинов');
await expect(page.locator('[data-settings-language="ru"]')).toHaveAttribute('aria-checked', 'true'); await expect(page.locator('[data-settings-language="ru"]')).toHaveAttribute('aria-checked', 'true');
+1
View File
@@ -35,6 +35,7 @@ export default {
'pluginView.select': 'Select a plugin view from the sidebar', 'pluginView.select': 'Select a plugin view from the sidebar',
'pluginView.selectHint': 'Plugin views will appear here', 'pluginView.selectHint': 'Plugin views will appear here',
'common.retry': '⟳ Retry', 'common.retry': '⟳ Retry',
'common.save': 'Save',
'common.source': 'Source', 'common.source': 'Source',
'common.status': 'Status', 'common.status': 'Status',
'common.capability': 'Capability', 'common.capability': 'Capability',
+1
View File
@@ -35,6 +35,7 @@ export default {
'pluginView.select': 'Выберите представление плагина на боковой панели', 'pluginView.select': 'Выберите представление плагина на боковой панели',
'pluginView.selectHint': 'Представления плагинов появятся здесь', 'pluginView.selectHint': 'Представления плагинов появятся здесь',
'common.retry': '⟳ Повторить', 'common.retry': '⟳ Повторить',
'common.save': 'Сохранить',
'common.source': 'Источник', 'common.source': 'Источник',
'common.status': 'Состояние', 'common.status': 'Состояние',
'common.capability': 'Возможность', 'common.capability': 'Возможность',
+1 -1
View File
@@ -364,7 +364,7 @@
if (e.key === 'Escape') cancelRename(); if (e.key === 'Escape') cancelRename();
}} }}
/> />
<button class="wt-btn wt-btn-small wt-always" on:click={() => commitRename(workspace)} title={tr('workspaceTree.saveRename')} type="button" disabled={busyId === id}>OK</button> <button class="wt-btn wt-btn-small wt-always" on:click={() => commitRename(workspace)} title={tr('workspaceTree.saveRename')} type="button" disabled={busyId === id}>{tr('common.save')}</button>
<button class="wt-btn wt-btn-small wt-always" on:click={cancelRename} title={tr('common.cancel')} type="button" disabled={busyId === id}>{tr('common.cancel')}</button> <button class="wt-btn wt-btn-small wt-always" on:click={cancelRename} title={tr('common.cancel')} type="button" disabled={busyId === id}>{tr('common.cancel')}</button>
{:else} {:else}
<button class="wt-label" on:click={() => selectWorkspace(workspace)} type="button">{id}</button> <button class="wt-label" on:click={() => selectWorkspace(workspace)} type="button">{id}</button>
+1 -9
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
+2 -26
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=
+18 -1
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.quitOnce.Do(func() {
a.allowQuit.Store(true) a.allowQuit.Store(true)
if a.ctx != nil { if a.ctx != nil {
quitNativeApplication(a.ctx) quitNativeApplication(a.ctx)
} }
})
} }
// NativeNotificationSender delivers scheduler items through the Wails runtime. // NativeNotificationSender delivers scheduler items through the Wails runtime.
+35 -2
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 }()
+22 -2
View File
@@ -81,6 +81,7 @@ type Manager struct {
mu sync.RWMutex mu sync.RWMutex
config *Config config *Config
configPath string configPath string
onLanguageChanged func(string)
} }
// DefaultConfigPath returns the default path for app settings. // DefaultConfigPath returns the default path for app settings.
@@ -230,13 +231,32 @@ func (m *Manager) UpdateLanguage(language string) error {
} }
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock()
if m.config == nil { if m.config == nil {
m.config = defaultConfig() m.config = defaultConfig()
} }
m.config.Language = language m.config.Language = language
m.config.LastOpenedAt = time.Now().UTC().Format(time.RFC3339) m.config.LastOpenedAt = time.Now().UTC().Format(time.RFC3339)
return m.saveLocked() err := m.saveLocked()
handler := m.onLanguageChanged
m.mu.Unlock()
if err != nil {
return err
}
if handler != nil {
handler(language)
}
return nil
}
// SetLanguageChangedHandler registers the shell callback invoked after a
// language preference has been durably updated.
func (m *Manager) SetLanguageChangedHandler(handler func(string)) {
if m == nil {
return
}
m.mu.Lock()
m.onLanguageChanged = handler
m.mu.Unlock()
} }
// UpdateSync replaces sync settings without changing unrelated app settings. // UpdateSync replaces sync settings without changing unrelated app settings.
+29
View File
@@ -3,6 +3,7 @@ package appsettings
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"reflect"
"testing" "testing"
) )
@@ -74,6 +75,34 @@ func TestUpdateLanguagePersistsAndPreservesUnrelatedSettings(t *testing.T) {
} }
} }
func TestUpdateLanguageNotifiesTheDesktopShellAfterPersisting(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
manager := NewManager(path)
if err := manager.Load(); err != nil {
t.Fatal(err)
}
var received []string
manager.SetLanguageChangedHandler(func(language string) {
received = append(received, language)
})
if err := manager.UpdateLanguage(LanguageRussian); err != nil {
t.Fatalf("UpdateLanguage: %v", err)
}
if got := manager.Get().Language; got != LanguageRussian {
t.Fatalf("persisted language = %q, want %q", got, LanguageRussian)
}
if !reflect.DeepEqual(received, []string{LanguageRussian}) {
t.Fatalf("language notifications = %#v, want [%q]", received, LanguageRussian)
}
if err := manager.UpdateLanguage("de"); err == nil {
t.Fatal("UpdateLanguage(de) succeeded, want validation failure")
}
if !reflect.DeepEqual(received, []string{LanguageRussian}) {
t.Fatalf("language notification fired after rejected update: %#v", received)
}
}
func TestLoad_CorruptConfig(t *testing.T) { func TestLoad_CorruptConfig(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
path := filepath.Join(dir, "config.json") path := filepath.Join(dir, "config.json")
+263 -23
View File
@@ -1,20 +1,39 @@
// Package tray owns the native tray menu wiring for the desktop shell. // Package tray owns the native tray menu wiring for the desktop shell.
package tray package tray
import "sync" 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. // MenuItem exposes click events from a native tray menu item.
type MenuItem interface { type MenuItem interface {
Clicked() <-chan struct{} Clicked() <-chan struct{}
SetTitle(title 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.
@@ -23,11 +42,72 @@ type Actions struct {
Quit func() Quit func()
} }
// Labels contains the user-visible text for one tray menu locale.
type Labels struct {
ShowTitle string
ShowTooltip string
QuitTitle string
QuitTooltip string
}
func englishLabels() Labels {
return Labels{
ShowTitle: "Show Verstak",
ShowTooltip: "Show the Verstak window",
QuitTitle: "Quit",
QuitTooltip: "Quit Verstak",
}
}
func russianLabels() Labels {
return Labels{
ShowTitle: "Показать Верстак",
ShowTooltip: "Показать окно Верстака",
QuitTitle: "Выйти",
QuitTooltip: "Завершить Верстак",
}
}
// LabelsForPreference resolves the saved language preference for native tray UI.
// System locale values are intentionally passed in by the shell so this package
// stays independent of process environment and is deterministic in tests.
func LabelsForPreference(preference string, systemLocales ...string) Labels {
if strings.EqualFold(strings.TrimSpace(preference), "ru") {
return russianLabels()
}
if strings.EqualFold(strings.TrimSpace(preference), "system") {
for _, locale := range systemLocales {
locale = strings.ToLower(strings.TrimSpace(locale))
if locale == "" {
continue
}
if strings.HasPrefix(locale, "ru") {
return russianLabels()
}
return englishLabels()
}
}
return englishLabels()
}
// Controller initializes one native tray and routes its menu actions. // Controller initializes one native tray and routes its menu actions.
type Controller struct { type Controller struct {
backend Backend backend Backend
icon []byte 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. // New creates a tray controller for one application process.
@@ -35,36 +115,196 @@ func New(backend Backend, icon []byte) *Controller {
return &Controller{ return &Controller{
backend: backend, backend: backend,
icon: append([]byte(nil), icon...), icon: append([]byte(nil), icon...),
labels: englishLabels(),
} }
} }
// Start registers the native tray without taking over the Wails event loop. // SetReadyChangedHandler receives readiness changes after native initialization
func (c *Controller) Start(actions Actions) { // has either completed or ended. It lets the Wails close policy avoid hiding a
if c == nil || c.backend == nil { // window before the tray can bring it back.
func (c *Controller) SetReadyChangedHandler(handler func(bool)) {
if c == nil {
return return
} }
c.start.Do(func() { c.mu.Lock()
c.backend.Register(func() { c.readyChanged = handler
c.backend.SetIcon(c.icon) c.mu.Unlock()
c.backend.SetTooltip("Verstak") }
show := c.backend.AddMenuItem("Show Verstak", "Show the Verstak window")
quit := c.backend.AddMenuItem("Quit", "Quit Verstak") // Ready reports whether the tray has completed icon and menu initialization.
if actions.Show != nil && show != nil { func (c *Controller) Ready() bool {
go routeClicks(show.Clicked(), actions.Show) 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 {
return
} }
if actions.Quit != nil && quit != nil { c.mu.Lock()
go routeClicks(quit.Clicked(), actions.Quit) c.labels = labels
show, quit := c.show, c.quit
c.mu.Unlock()
applyLabels(show, quit, labels)
}
// 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
} }
}, nil) 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.mu.RLock()
labels := c.labels
actions := c.actions
c.mu.RUnlock()
show, err := c.backend.AddMenuItem(labels.ShowTitle, labels.ShowTooltip)
if err != nil || show == nil {
if err == nil {
err = errors.New("show menu item is nil")
}
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 {
go routeClicks(show.Clicked(), func() {
log.Printf("[tray] Show command")
actions.Show()
})
}
if actions.Quit != nil {
go routeClicks(quit.Clicked(), func() {
log.Printf("[tray] Quit command")
actions.Quit()
})
}
c.setReady(true)
log.Printf("[tray] native tray is ready")
})
}
func (c *Controller) onLeftClick() {
if c == nil {
return
}
log.Printf("[tray] left click")
if !c.Ready() {
log.Printf("[tray] left click ignored while tray is not ready")
return
}
c.mu.RLock()
show := c.actions.Show
c.mu.RUnlock()
if show != nil {
show()
}
}
func (c *Controller) onExit() {
if c == nil {
return
}
c.setReady(false)
log.Printf("[tray] native message loop ended; falling back to normal window close")
}
func (c *Controller) fail(stage string, err error) {
c.setReady(false)
log.Printf("[tray] %s failed: %v; falling back to normal window close", stage, err)
c.Stop()
}
func (c *Controller) setReady(ready bool) {
if c == nil || c.ready.Swap(ready) == ready {
return
}
c.mu.RLock()
handler := c.readyChanged
c.mu.RUnlock()
if handler != nil {
handler(ready)
}
}
func applyLabels(show, quit MenuItem, labels Labels) {
if show != nil {
show.SetTitle(labels.ShowTitle)
show.SetTooltip(labels.ShowTooltip)
}
if quit != nil {
quit.SetTitle(labels.QuitTitle)
quit.SetTooltip(labels.QuitTooltip)
}
} }
// 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()) {
+163 -27
View File
@@ -1,47 +1,77 @@
package tray package tray
import ( import (
"errors"
"sync"
"testing" "testing"
"time" "time"
) )
type fakeMenuItem struct { type fakeMenuItem struct {
clicked chan struct{} clicked chan struct{}
title 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 }
type fakeBackend struct { type fakeBackend struct {
callbacks BackendCallbacks
icon []byte icon []byte
tooltip string tooltip string
items map[string]*fakeMenuItem items map[string]*fakeMenuItem
quitCalls int startErr error
registering bool 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)} 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{}) {
@@ -53,39 +83,145 @@ 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 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"))
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)
}
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)
}
backend.exited()
if controller.Ready() {
t.Fatal("tray stayed ready after backend exit")
}
}
func TestLabelsForSystemRussianLocale(t *testing.T) {
labels := LabelsForPreference("system", "", "ru_RU.UTF-8", "en_US.UTF-8")
if labels.ShowTitle != "Показать Верстак" || labels.QuitTitle != "Выйти" {
t.Fatalf("system Russian labels = %#v", labels)
}
labels = LabelsForPreference("system", "C.UTF-8", "", "ru_RU.UTF-8")
if labels.ShowTitle != "Show Verstak" || labels.QuitTitle != "Quit" {
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)
} }
} }
-14
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
}
+16
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" }
+43
View File
@@ -0,0 +1,43 @@
//go:build linux
package tray
import (
"bytes"
"image"
"image/color"
_ "image/png"
"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")
}
assertVerstakBrand(t, icon)
}
func assertVerstakBrand(t *testing.T, icon []byte) {
t.Helper()
image, _, err := image.Decode(bytes.NewReader(icon))
if err != nil {
t.Fatalf("decode tray icon: %v", err)
}
want := color.NRGBA{R: 0x1c, G: 0x2f, B: 0x4a, A: 0xff}
for y := image.Bounds().Min.Y; y < image.Bounds().Max.Y; y++ {
for x := image.Bounds().Min.X; x < image.Bounds().Max.X; x++ {
if color.NRGBAModel.Convert(image.At(x, y)) == want {
return
}
}
}
t.Fatal("tray icon does not contain the Verstak navy brand color")
}
+16
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" }
+37
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)
}
}
}
+130 -13
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{} {
@@ -39,3 +144,15 @@ func (item systrayMenuItem) Clicked() <-chan struct{} {
} }
return item.item.ClickedCh return item.item.ClickedCh
} }
func (item systrayMenuItem) SetTitle(title string) {
if item.item != nil {
item.item.SetTitle(title)
}
}
func (item systrayMenuItem) SetTooltip(tooltip string) {
if item.item != nil {
item.item.SetTooltip(tooltip)
}
}
@@ -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: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

+11 -1
View File
@@ -230,6 +230,14 @@ 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 {
return tray.LabelsForPreference(language, os.Getenv("LC_ALL"), os.Getenv("LC_MESSAGES"), os.Getenv("LANG"))
}
trayController.SetLabels(trayLabels(cfg.Language))
appSettingsMgr.SetLanguageChangedHandler(func(language string) {
trayController.SetLabels(trayLabels(language))
})
if browserReceiver != nil { if browserReceiver != nil {
browserReceiverServer, err := browserreceiver.Start(browserreceiver.DefaultAddr, browserReceiver) browserReceiverServer, err := browserreceiver.Start(browserreceiver.DefaultAddr, browserReceiver)
if err != nil { if err != nil {
@@ -251,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()
+1 -1
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
+7
View File
@@ -0,0 +1,7 @@
## Highlights
- First public alpha of the local Verstak workbench.
- Introduces Deals, workspace templates, the Overview, Files, Notes, Journal, activity tracking, browser intake, and the official plugin system.
- Includes portable Windows, Debian, and AppImage packages for early testing.
**Commit history**: https://github.com/mirivlad/verstak/commits/v0.1.0-alpha.1
+7
View File
@@ -0,0 +1,7 @@
## Highlights
- Adds scheduled native notifications and Todo reminders.
- Keeps Verstak available in the system tray, including the required Linux tray runtime in packages.
- Improves the public alpha installation and troubleshooting documentation.
**Full Changelog**: https://github.com/mirivlad/verstak/compare/v0.1.0-alpha.1...v0.1.0-alpha.2
+7
View File
@@ -0,0 +1,7 @@
## Highlights
- Replaces technical workspace terminology in the main interface with the user-facing term “Deal”.
- Improves Russian localization, operation errors, template guidance, and the plugin manager filters.
- Makes global Journal and Secrets flows usable and improves Todo reminder time selection.
**Full Changelog**: https://github.com/mirivlad/verstak/compare/v0.1.0-alpha.2...v0.1.0-alpha.3
+9
View File
@@ -0,0 +1,9 @@
## Highlights
- The Deal rename dialog now uses the selected application language.
- Native tray actions use the selected language and refresh when it changes.
- The bundled official plugins include clearer, localized file and folder labels.
## Packages
This prerelease provides a portable Windows archive, a Debian package, and an AppImage for Linux.
+31
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.
+27
View File
@@ -0,0 +1,27 @@
## Highlights
- Replaced the generic Wails image in the Linux tray and Windows application
resources with the current Verstak workbench icon.
- The desktop build now generates its PNG and multi-resolution ICO resources
from the committed Verstak SVG before packaging, preventing a generic icon
from returning in a clean build.
- The Windows ICO contains 16, 20, 24, 32, 48, and 256 pixel variants; Linux
uses the matching transparent PNG tray image.
## Главное
- Стандартный знак Wails в Linux-трее и ресурсах Windows заменён на актуальный
фирменный значок Верстака.
- Перед упаковкой desktop-сборка создаёт PNG и многомасштабный ICO из SVG,
хранящегося в репозитории. Это не позволяет стандартному значку вернуться в
чистой сборке.
- Windows ICO содержит варианты 16, 20, 24, 32, 48 и 256 пикселей; Linux
использует соответствующий прозрачный PNG для трея.
## Packages / Пакеты
This prerelease provides a portable Windows archive, a Debian package, and an
AppImage for Linux.
В этот prerelease входят переносимый архив для Windows, Debian-пакет и AppImage
для Linux.
+2
View File
@@ -35,6 +35,8 @@ fi
cd "$ROOT" cd "$ROOT"
echo "=== verstak desktop Windows amd64 build ===" echo "=== verstak desktop Windows amd64 build ==="
"$ROOT/scripts/generate-brand-icons.sh"
if [[ ! -d "$ROOT/frontend/node_modules" ]]; then if [[ ! -d "$ROOT/frontend/node_modules" ]]; then
if [[ -f "$ROOT/frontend/package-lock.json" ]]; then if [[ -f "$ROOT/frontend/package-lock.json" ]]; then
(cd "$ROOT/frontend" && npm ci --no-audit --no-fund) (cd "$ROOT/frontend" && npm ci --no-audit --no-fund)
+2 -9
View File
@@ -6,6 +6,8 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
echo "=== verstak-desktop build ===" echo "=== verstak-desktop build ==="
echo "" echo ""
"$ROOT/scripts/generate-brand-icons.sh"
# ── Dependency checks ── # ── Dependency checks ──
echo "[deps]" echo "[deps]"
if ! command -v go &>/dev/null; then if ! command -v go &>/dev/null; then
@@ -26,15 +28,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]"
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
MAGICK="${MAGICK_BIN:-magick}"
SOURCE="$ROOT/packaging/linux/verstak.svg"
if ! command -v "$MAGICK" >/dev/null; then
echo "ImageMagick is required to generate Verstak application icons: $MAGICK not found" >&2
exit 1
fi
if [[ ! -f "$SOURCE" ]]; then
echo "Verstak SVG icon source is missing: $SOURCE" >&2
exit 1
fi
render_png() {
local size="$1"
local target="$2"
mkdir -p "$(dirname "$target")"
"$MAGICK" -background none "$SOURCE" -resize "${size}x${size}" "PNG32:$target"
}
render_ico() {
local target="$1"
shift
local temporary
temporary="$(mktemp -d)"
local images=()
for size in "$@"; do
local image="$temporary/${size}.png"
render_png "$size" "$image"
images+=("$image")
done
mkdir -p "$(dirname "$target")"
"$MAGICK" "${images[@]}" "$target"
rm -rf "$temporary"
}
render_png 256 "$ROOT/internal/shell/tray/verstak.png"
render_ico "$ROOT/internal/shell/tray/verstak.ico" 16 20 24 32 48 256
# Wails consumes this PNG while it prepares the Windows executable resources.
# Both files are generated build inputs and therefore deliberately ignored by Git.
render_png 1024 "$ROOT/build/appicon.png"
render_ico "$ROOT/build/windows/icon.ico" 16 32 48 64 128 256
echo "generated Verstak tray and application icons from $SOURCE"
-5
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
+12 -1
View File
@@ -6,6 +6,7 @@ VERSION="${1:-}"
REPOSITORY="mirivlad/verstak" REPOSITORY="mirivlad/verstak"
RELEASE_SCRIPT="${VERSTAK_RELEASE_SCRIPT:-$ROOT/scripts/release.sh}" RELEASE_SCRIPT="${VERSTAK_RELEASE_SCRIPT:-$ROOT/scripts/release.sh}"
RELEASE_DIR="${VERSTAK_RELEASE_DIR:-$ROOT/release}" RELEASE_DIR="${VERSTAK_RELEASE_DIR:-$ROOT/release}"
RELEASE_NOTES_DIR="${VERSTAK_RELEASE_NOTES_DIR:-$ROOT/release-notes}"
GIT_BIN="${GIT_BIN:-git}" GIT_BIN="${GIT_BIN:-git}"
GH_BIN="${GH_BIN:-gh}" GH_BIN="${GH_BIN:-gh}"
@@ -65,7 +66,17 @@ fi
if "$GH_BIN" release view "$VERSION" --repo "$REPOSITORY" >/dev/null 2>&1; then if "$GH_BIN" release view "$VERSION" --repo "$REPOSITORY" >/dev/null 2>&1; then
"$GH_BIN" release upload "$VERSION" "${ASSETS[@]}" --repo "$REPOSITORY" --clobber "$GH_BIN" release upload "$VERSION" "${ASSETS[@]}" --repo "$REPOSITORY" --clobber
else else
RELEASE_OPTIONS=(--generate-notes --verify-tag) NOTES_FILE="$RELEASE_NOTES_DIR/$VERSION.md"
if [[ ! -s "$NOTES_FILE" ]]; then
echo "human-readable release notes are required: $NOTES_FILE" >&2
exit 1
fi
RELEASE_OPTIONS=(--notes-file "$NOTES_FILE" --generate-notes --verify-tag)
PREVIOUS_TAG="$("$GIT_BIN" describe --tags --abbrev=0 "${HEAD}^" 2>/dev/null || true)"
if [[ -n "$PREVIOUS_TAG" ]]; then
RELEASE_OPTIONS+=(--notes-start-tag "$PREVIOUS_TAG")
fi
if [[ "$VERSION" == *-* ]]; then if [[ "$VERSION" == *-* ]]; then
RELEASE_OPTIONS+=(--prerelease) RELEASE_OPTIONS+=(--prerelease)
else else
+10 -3
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
@@ -48,6 +53,8 @@ grep -Fq 'LinkId=2124701' "$ROOT/README.md"
grep -Fq 'WebView2 Runtime' "$ROOT/README.md" grep -Fq 'WebView2 Runtime' "$ROOT/README.md"
grep -Fq 'package-deb.sh' "$ROOT/scripts/release.sh" grep -Fq 'package-deb.sh' "$ROOT/scripts/release.sh"
grep -Fq 'package-appimage.sh' "$ROOT/scripts/release.sh" grep -Fq 'package-appimage.sh' "$ROOT/scripts/release.sh"
grep -Fq 'generate-brand-icons.sh' "$ROOT/scripts/build.sh"
grep -Fq 'generate-brand-icons.sh' "$ROOT/scripts/build-windows.sh"
grep -Fq 'package-windows-portable.sh' "$ROOT/scripts/release.sh" grep -Fq 'package-windows-portable.sh' "$ROOT/scripts/release.sh"
git -C "$ROOT" check-ignore -q verstak-desktop-res.syso git -C "$ROOT" check-ignore -q verstak-desktop-res.syso
grep -Fq 'chmod -R a+rX' "$ROOT/scripts/build-linux-bundle.sh" grep -Fq 'chmod -R a+rX' "$ROOT/scripts/build-linux-bundle.sh"
+7 -1
View File
@@ -18,9 +18,10 @@ if [[ ! -x "$PUBLISHER" ]]; then
exit 1 exit 1
fi fi
mkdir -p "$WORK/bin" "$WORK/release" "$WORK/state" mkdir -p "$WORK/bin" "$WORK/release" "$WORK/release-notes" "$WORK/state"
LOG="$WORK/log" LOG="$WORK/log"
export LOG export LOG
printf '## Highlights\n\nHuman-readable release notes.\n' > "$WORK/release-notes/$VERSION.md"
cat > "$WORK/release.sh" <<'SCRIPT' cat > "$WORK/release.sh" <<'SCRIPT'
#!/usr/bin/env bash #!/usr/bin/env bash
@@ -51,6 +52,7 @@ case "${1:-}" in
fi fi
echo test-commit echo test-commit
;; ;;
describe) echo v0.0.0-previous ;;
tag) touch "$TEST_STATE/tag"; printf 'tag:%s\n' "${3:-}" >> "$LOG" ;; tag) touch "$TEST_STATE/tag"; printf 'tag:%s\n' "${3:-}" >> "$LOG" ;;
push) printf 'push:%s:%s\n' "${2:-}" "${3:-}" >> "$LOG" ;; push) printf 'push:%s:%s\n' "${2:-}" "${3:-}" >> "$LOG" ;;
*) echo "unexpected git invocation: $*" >&2; exit 1 ;; *) echo "unexpected git invocation: $*" >&2; exit 1 ;;
@@ -76,6 +78,7 @@ chmod +x "$WORK/bin/gh"
run_publisher() { run_publisher() {
VERSTAK_RELEASE_SCRIPT="$WORK/release.sh" \ VERSTAK_RELEASE_SCRIPT="$WORK/release.sh" \
VERSTAK_RELEASE_DIR="$WORK/release" \ VERSTAK_RELEASE_DIR="$WORK/release" \
VERSTAK_RELEASE_NOTES_DIR="$WORK/release-notes" \
GIT_BIN="$WORK/bin/git" \ GIT_BIN="$WORK/bin/git" \
GH_BIN="$WORK/bin/gh" \ GH_BIN="$WORK/bin/gh" \
EXPECTED_ROOT="$ROOT" \ EXPECTED_ROOT="$ROOT" \
@@ -92,6 +95,9 @@ for asset in "${ASSET_NAMES[@]}"; do
grep -F "$asset" "$LOG" >/dev/null grep -F "$asset" "$LOG" >/dev/null
done done
grep -F "SHA256SUMS" "$LOG" >/dev/null grep -F "SHA256SUMS" "$LOG" >/dev/null
grep -F -- "--notes-file $WORK/release-notes/$VERSION.md" "$LOG" >/dev/null
grep -F -- "--generate-notes" "$LOG" >/dev/null
grep -F -- "--notes-start-tag v0.0.0-previous" "$LOG" >/dev/null
grep -F -- "--prerelease" "$LOG" >/dev/null grep -F -- "--prerelease" "$LOG" >/dev/null
run_publisher run_publisher