fix: restore Windows tray lifecycle

This commit is contained in:
2026-07-15 18:56:22 +08:00
parent 3c1f2c7177
commit 6eeebd39ad
25 changed files with 711 additions and 203 deletions
+184 -33
View File
@@ -2,10 +2,15 @@
package tray
import (
"errors"
"log"
"strings"
"sync"
"sync/atomic"
)
var errBackendUnavailable = errors.New("tray backend is unavailable")
// MenuItem exposes click events from a native tray menu item.
type MenuItem interface {
Clicked() <-chan struct{}
@@ -13,13 +18,22 @@ type MenuItem interface {
SetTooltip(tooltip string)
}
// BackendCallbacks are invoked by the native tray implementation.
// A backend keeps the platform-native secondary-click handler enabled so the
// operating system opens the menu on a right click.
type BackendCallbacks struct {
Ready func()
Exit func()
LeftClick func()
}
// Backend is the platform tray implementation.
type Backend interface {
Register(onReady func(), onExit func())
SetIcon(icon []byte)
SetTooltip(tooltip string)
AddMenuItem(title, tooltip string) MenuItem
Quit()
Start(callbacks BackendCallbacks) error
SetIcon(icon []byte) error
SetTooltip(tooltip string) error
AddMenuItem(title, tooltip string) (MenuItem, error)
Stop()
}
// Actions are executed from the native tray menu.
@@ -80,11 +94,20 @@ func LabelsForPreference(preference string, systemLocales ...string) Labels {
type Controller struct {
backend Backend
icon []byte
start sync.Once
mu sync.RWMutex
labels Labels
show MenuItem
quit MenuItem
startOnce sync.Once
readyOnce sync.Once
stopOnce sync.Once
started atomic.Bool
ready atomic.Bool
mu sync.RWMutex
labels Labels
actions Actions
show MenuItem
quit MenuItem
startErr error
readyChanged func(bool)
}
// New creates a tray controller for one application process.
@@ -96,6 +119,23 @@ func New(backend Backend, icon []byte) *Controller {
}
}
// SetReadyChangedHandler receives readiness changes after native initialization
// has either completed or ended. It lets the Wails close policy avoid hiding a
// window before the tray can bring it back.
func (c *Controller) SetReadyChangedHandler(handler func(bool)) {
if c == nil {
return
}
c.mu.Lock()
c.readyChanged = handler
c.mu.Unlock()
}
// Ready reports whether the tray has completed icon and menu initialization.
func (c *Controller) Ready() bool {
return c != nil && c.ready.Load()
}
// SetLabels updates the current and future native tray menu labels.
func (c *Controller) SetLabels(labels Labels) {
if c == nil {
@@ -108,35 +148,142 @@ func (c *Controller) SetLabels(labels Labels) {
applyLabels(show, quit, labels)
}
// Start registers the native tray without taking over the Wails event loop.
func (c *Controller) Start(actions Actions) {
// Start connects the tray to Wails without taking over Wails' GUI event loop.
// Ready remains false until the backend callback and all required menu setup
// finish successfully.
func (c *Controller) Start(actions Actions) error {
if c == nil || c.backend == nil {
return errBackendUnavailable
}
c.startOnce.Do(func() {
c.mu.Lock()
c.actions = actions
c.mu.Unlock()
c.started.Store(true)
log.Printf("[tray] starting native backend")
err := c.backend.Start(BackendCallbacks{
Ready: c.onReady,
Exit: c.onExit,
LeftClick: c.onLeftClick,
})
if err != nil {
c.mu.Lock()
c.startErr = err
c.mu.Unlock()
c.started.Store(false)
c.setReady(false)
log.Printf("[tray] backend start failed: %v; falling back to normal window close", err)
}
})
c.mu.RLock()
err := c.startErr
c.mu.RUnlock()
return err
}
func (c *Controller) onReady() {
if c == nil {
return
}
c.start.Do(func() {
c.backend.Register(func() {
c.backend.SetIcon(c.icon)
c.backend.SetTooltip("Verstak")
c.mu.RLock()
labels := c.labels
c.mu.RUnlock()
show := c.backend.AddMenuItem(labels.ShowTitle, labels.ShowTooltip)
quit := c.backend.AddMenuItem(labels.QuitTitle, labels.QuitTooltip)
c.mu.Lock()
c.show, c.quit = show, quit
labels = c.labels
c.mu.Unlock()
applyLabels(show, quit, labels)
if actions.Show != nil && show != nil {
go routeClicks(show.Clicked(), actions.Show)
if !c.started.Load() {
log.Printf("[tray] ready callback ignored after backend startup failed")
return
}
c.readyOnce.Do(func() {
log.Printf("[tray] native backend reported ready")
if err := c.backend.SetIcon(c.icon); err != nil {
c.fail("icon setup", err)
return
}
if err := c.backend.SetTooltip("Verstak"); err != nil {
c.fail("tooltip setup", err)
return
}
c.mu.RLock()
labels := c.labels
actions := c.actions
c.mu.RUnlock()
show, err := c.backend.AddMenuItem(labels.ShowTitle, labels.ShowTooltip)
if err != nil || show == nil {
if err == nil {
err = errors.New("show menu item is nil")
}
if actions.Quit != nil && quit != nil {
go routeClicks(quit.Clicked(), actions.Quit)
c.fail("show menu creation", err)
return
}
quit, err := c.backend.AddMenuItem(labels.QuitTitle, labels.QuitTooltip)
if err != nil || quit == nil {
if err == nil {
err = errors.New("quit menu item is nil")
}
}, nil)
c.fail("quit menu creation", err)
return
}
c.mu.Lock()
c.show, c.quit = show, quit
labels = c.labels
c.mu.Unlock()
applyLabels(show, quit, labels)
if actions.Show != nil {
go routeClicks(show.Clicked(), func() {
log.Printf("[tray] Show command")
actions.Show()
})
}
if actions.Quit != nil {
go routeClicks(quit.Clicked(), func() {
log.Printf("[tray] Quit command")
actions.Quit()
})
}
c.setReady(true)
log.Printf("[tray] native tray is ready")
})
}
func (c *Controller) onLeftClick() {
if c == nil {
return
}
log.Printf("[tray] left click")
if !c.Ready() {
log.Printf("[tray] left click ignored while tray is not ready")
return
}
c.mu.RLock()
show := c.actions.Show
c.mu.RUnlock()
if show != nil {
show()
}
}
func (c *Controller) onExit() {
if c == nil {
return
}
c.setReady(false)
log.Printf("[tray] native message loop ended; falling back to normal window close")
}
func (c *Controller) fail(stage string, err error) {
c.setReady(false)
log.Printf("[tray] %s failed: %v; falling back to normal window close", stage, err)
c.Stop()
}
func (c *Controller) setReady(ready bool) {
if c == nil || c.ready.Swap(ready) == ready {
return
}
c.mu.RLock()
handler := c.readyChanged
c.mu.RUnlock()
if handler != nil {
handler(ready)
}
}
func applyLabels(show, quit MenuItem, labels Labels) {
if show != nil {
show.SetTitle(labels.ShowTitle)
@@ -150,10 +297,14 @@ func applyLabels(show, quit MenuItem, labels Labels) {
// Stop releases the native tray after Wails has begun application shutdown.
func (c *Controller) Stop() {
if c == nil || c.backend == nil {
if c == nil || c.backend == nil || !c.started.Load() {
return
}
c.backend.Quit()
c.stopOnce.Do(func() {
c.setReady(false)
log.Printf("[tray] stopping native backend")
c.backend.Stop()
})
}
func routeClicks(clicked <-chan struct{}, action func()) {
+137 -46
View File
@@ -1,6 +1,8 @@
package tray
import (
"errors"
"sync"
"testing"
"time"
)
@@ -11,47 +13,65 @@ type fakeMenuItem struct {
tooltip string
}
func (i *fakeMenuItem) Clicked() <-chan struct{} {
return i.clicked
}
func (i *fakeMenuItem) SetTitle(title string) {
i.title = title
}
func (i *fakeMenuItem) SetTooltip(tooltip string) {
i.tooltip = tooltip
}
func (i *fakeMenuItem) Clicked() <-chan struct{} { return i.clicked }
func (i *fakeMenuItem) SetTitle(title string) { i.title = title }
func (i *fakeMenuItem) SetTooltip(tooltip string) { i.tooltip = tooltip }
type fakeBackend struct {
icon []byte
tooltip string
items map[string]*fakeMenuItem
quitCalls int
registering bool
callbacks BackendCallbacks
icon []byte
tooltip string
items map[string]*fakeMenuItem
startErr error
iconErr error
menuErr error
starts int
stops int
}
func (b *fakeBackend) Register(onReady func(), _ func()) {
b.registering = true
onReady()
func (b *fakeBackend) Start(callbacks BackendCallbacks) error {
b.starts++
b.callbacks = callbacks
return b.startErr
}
func (b *fakeBackend) SetIcon(icon []byte) {
func (b *fakeBackend) SetIcon(icon []byte) error {
b.icon = append([]byte(nil), icon...)
return b.iconErr
}
func (b *fakeBackend) SetTooltip(tooltip string) {
func (b *fakeBackend) SetTooltip(tooltip string) error {
b.tooltip = tooltip
return nil
}
func (b *fakeBackend) AddMenuItem(title, _ string) MenuItem {
item := &fakeMenuItem{clicked: make(chan struct{}, 1), title: title}
func (b *fakeBackend) AddMenuItem(title, tooltip string) (MenuItem, error) {
if b.menuErr != nil {
return nil, b.menuErr
}
item := &fakeMenuItem{clicked: make(chan struct{}, 1), title: title, tooltip: tooltip}
b.items[title] = item
return item
return item, nil
}
func (b *fakeBackend) Quit() {
b.quitCalls++
func (b *fakeBackend) Stop() { b.stops++ }
func (b *fakeBackend) ready() {
if b.callbacks.Ready != nil {
b.callbacks.Ready()
}
}
func (b *fakeBackend) exited() {
if b.callbacks.Exit != nil {
b.callbacks.Exit()
}
}
func (b *fakeBackend) leftClick() {
if b.callbacks.LeftClick != nil {
b.callbacks.LeftClick()
}
}
func waitFor(t *testing.T, signal <-chan struct{}) {
@@ -63,64 +83,112 @@ func waitFor(t *testing.T, signal <-chan struct{}) {
}
}
func TestControllerInitializesTrayAndRoutesMenuActions(t *testing.T) {
func TestControllerBecomesReadyOnlyAfterBackendCallback(t *testing.T) {
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
showCalls := make(chan struct{}, 1)
quitCalls := make(chan struct{}, 1)
controller := New(backend, []byte{1, 2, 3})
controller.Start(Actions{
if err := controller.Start(Actions{}); err != nil {
t.Fatalf("Start() error = %v", err)
}
if controller.Ready() {
t.Fatal("tray became ready before backend callback")
}
backend.ready()
if !controller.Ready() {
t.Fatal("tray did not become ready after backend callback")
}
if string(backend.icon) != string([]byte{1, 2, 3}) || backend.tooltip != "Verstak" {
t.Fatalf("tray initialization = icon:%v tooltip:%q", backend.icon, backend.tooltip)
}
}
func TestControllerRoutesLeftClickAndMenuActionsToShowAndQuit(t *testing.T) {
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
showCalls := make(chan struct{}, 2)
quitCalls := make(chan struct{}, 1)
controller := New(backend, []byte{1})
if err := controller.Start(Actions{
Show: func() { showCalls <- struct{}{} },
Quit: func() { quitCalls <- struct{}{} },
})
if !backend.registering || string(backend.icon) != string([]byte{1, 2, 3}) || backend.tooltip != "Verstak" {
t.Fatalf("tray initialization = registering:%t icon:%v tooltip:%q", backend.registering, backend.icon, backend.tooltip)
}); err != nil {
t.Fatalf("Start() error = %v", err)
}
backend.ready()
backend.leftClick()
waitFor(t, showCalls)
showItem := backend.items["Show Verstak"]
quitItem := backend.items["Quit"]
if showItem == nil || quitItem == nil {
t.Fatalf("tray menu = %#v, want Show Verstak and Quit", backend.items)
}
showItem.clicked <- struct{}{}
waitFor(t, showCalls)
quitItem.clicked <- struct{}{}
waitFor(t, quitCalls)
}
func TestControllerStopsNativeTrayBackend(t *testing.T) {
func TestControllerStopCallsBackendOnce(t *testing.T) {
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
controller := New(backend, []byte{1})
if err := controller.Start(Actions{}); err != nil {
t.Fatalf("Start() error = %v", err)
}
controller.Stop()
controller.Stop()
if backend.quitCalls != 1 {
t.Fatalf("backend quit calls = %d, want 1", backend.quitCalls)
if backend.stops != 1 {
t.Fatalf("backend stop calls = %d, want 1", backend.stops)
}
}
func TestControllerUsesAndUpdatesLocalizedLabels(t *testing.T) {
func TestControllerStartupAndSetupFailuresNeverBecomeReady(t *testing.T) {
for name, backend := range map[string]*fakeBackend{
"start": {items: make(map[string]*fakeMenuItem), startErr: errors.New("start failed")},
"icon": {items: make(map[string]*fakeMenuItem), iconErr: errors.New("icon failed")},
"menu": {items: make(map[string]*fakeMenuItem), menuErr: errors.New("menu failed")},
} {
t.Run(name, func(t *testing.T) {
controller := New(backend, []byte{1})
err := controller.Start(Actions{})
if name == "start" && err == nil {
t.Fatal("Start() error = nil, want startup failure")
}
if name != "start" && err != nil {
t.Fatalf("Start() error = %v, want nil before ready callback", err)
}
backend.ready()
if controller.Ready() {
t.Fatal("failed tray became ready")
}
})
}
}
func TestControllerExitRevokesReadinessAndKeepsLocalizedMenuItems(t *testing.T) {
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
controller := New(backend, []byte{1})
controller.SetLabels(LabelsForPreference("ru"))
controller.Start(Actions{})
if err := controller.Start(Actions{}); err != nil {
t.Fatalf("Start() error = %v", err)
}
backend.ready()
show := backend.items["Показать Верстак"]
quit := backend.items["Выйти"]
if show == nil || quit == nil {
t.Fatalf("Russian tray menu = %#v", backend.items)
}
if show.tooltip != "Показать окно Верстака" || quit.tooltip != "Завершить Верстак" {
t.Fatalf("Russian tray tooltips = show:%q quit:%q", show.tooltip, quit.tooltip)
}
controller.SetLabels(LabelsForPreference("en"))
if show.title != "Show Verstak" || quit.title != "Quit" {
t.Fatalf("English tray menu after update = show:%q quit:%q", show.title, quit.title)
}
if show.tooltip != "Show the Verstak window" || quit.tooltip != "Quit Verstak" {
t.Fatalf("English tray tooltips after update = show:%q quit:%q", show.tooltip, quit.tooltip)
backend.exited()
if controller.Ready() {
t.Fatal("tray stayed ready after backend exit")
}
}
@@ -134,3 +202,26 @@ func TestLabelsForSystemRussianLocale(t *testing.T) {
t.Fatalf("higher-priority system locale must win, got %#v", labels)
}
}
func TestControllerReadyNotificationIsSafeAcrossCallbacks(t *testing.T) {
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
controller := New(backend, []byte{1})
var changes []bool
var mu sync.Mutex
controller.SetReadyChangedHandler(func(ready bool) {
mu.Lock()
changes = append(changes, ready)
mu.Unlock()
})
if err := controller.Start(Actions{}); err != nil {
t.Fatalf("Start() error = %v", err)
}
backend.ready()
backend.exited()
mu.Lock()
defer mu.Unlock()
if len(changes) != 2 || !changes[0] || changes[1] {
t.Fatalf("ready changes = %#v, want [true false]", changes)
}
}
-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" }
+18
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")
}
}
+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)
}
}
}
+118 -13
View File
@@ -1,36 +1,141 @@
package tray
import "github.com/getlantern/systray"
import (
"errors"
"fmt"
"os"
"strings"
"sync"
type systrayBackend struct{}
"fyne.io/systray"
)
var (
runWithExternalLoop = systray.RunWithExternalLoop
setOnTapped = systray.SetOnTapped
)
type systrayBackend struct {
mu sync.Mutex
end func()
iconPath string
started bool
stopRequested bool
}
type systrayMenuItem struct {
item *systray.MenuItem
}
// NewNativeBackend creates the cross-platform native tray backend.
// NewNativeBackend creates the cross-platform tray backend. It uses
// RunWithExternalLoop so the native tray message loop runs alongside Wails.
func NewNativeBackend() Backend {
return systrayBackend{}
return &systrayBackend{}
}
func (systrayBackend) Register(onReady func(), onExit func()) {
systray.Register(onReady, onExit)
func (b *systrayBackend) Start(callbacks BackendCallbacks) (err error) {
if b == nil {
return errBackendUnavailable
}
b.mu.Lock()
if b.started {
b.mu.Unlock()
return errors.New("native tray backend was already started")
}
b.started = true
b.mu.Unlock()
defer func() {
if recovered := recover(); recovered != nil {
err = fmt.Errorf("native tray startup panic: %v", recovered)
}
}()
start, end := runWithExternalLoop(callbacks.Ready, callbacks.Exit)
if callbacks.LeftClick != nil {
setOnTapped(callbacks.LeftClick)
}
// The Ready callback is asynchronous on Windows and can synchronously
// decide that startup failed. Start the native loop before publishing the
// end function so a concurrent Stop is never left with a half-started loop.
start()
b.mu.Lock()
stopRequested := b.stopRequested
if !stopRequested {
b.end = end
}
b.mu.Unlock()
if stopRequested {
end()
}
return nil
}
func (systrayBackend) SetIcon(icon []byte) {
systray.SetIcon(icon)
func (b *systrayBackend) SetIcon(icon []byte) error {
if b == nil || len(icon) == 0 {
return errors.New("tray icon is empty")
}
file, err := os.CreateTemp("", "verstak-tray-*"+IconFileExtension())
if err != nil {
return fmt.Errorf("create tray icon file: %w", err)
}
path := file.Name()
if _, err := file.Write(icon); err != nil {
file.Close()
os.Remove(path)
return fmt.Errorf("write tray icon file: %w", err)
}
if err := file.Close(); err != nil {
os.Remove(path)
return fmt.Errorf("close tray icon file: %w", err)
}
if err := systray.SetIconFromFilePath(path); err != nil {
os.Remove(path)
return fmt.Errorf("set native tray icon: %w", err)
}
b.mu.Lock()
previous := b.iconPath
b.iconPath = path
b.mu.Unlock()
if previous != "" && previous != path {
_ = os.Remove(previous)
}
return nil
}
func (systrayBackend) SetTooltip(tooltip string) {
func (b *systrayBackend) SetTooltip(tooltip string) error {
if strings.TrimSpace(tooltip) == "" {
return errors.New("tray tooltip is empty")
}
systray.SetTooltip(tooltip)
return nil
}
func (systrayBackend) AddMenuItem(title, tooltip string) MenuItem {
return systrayMenuItem{item: systray.AddMenuItem(title, tooltip)}
func (b *systrayBackend) AddMenuItem(title, tooltip string) (MenuItem, error) {
if strings.TrimSpace(title) == "" {
return nil, errors.New("tray menu title is empty")
}
item := systray.AddMenuItem(title, tooltip)
if item == nil {
return nil, errors.New("native tray menu item is nil")
}
return systrayMenuItem{item: item}, nil
}
func (systrayBackend) Quit() {
systray.Quit()
func (b *systrayBackend) Stop() {
if b == nil {
return
}
b.mu.Lock()
end, iconPath := b.end, b.iconPath
b.end = nil
b.iconPath = ""
b.stopRequested = true
b.mu.Unlock()
if end != nil {
end()
}
if iconPath != "" {
_ = os.Remove(iconPath)
}
}
func (item systrayMenuItem) Clicked() <-chan struct{} {
@@ -0,0 +1,30 @@
package tray
import "testing"
func TestBackendStopsWhenReadyCallbackStopsDuringStartup(t *testing.T) {
originalRun := runWithExternalLoop
originalSetOnTapped := setOnTapped
defer func() {
runWithExternalLoop = originalRun
setOnTapped = originalSetOnTapped
}()
var startCalls, endCalls int
runWithExternalLoop = func(onReady, _ func()) (func(), func()) {
onReady()
return func() { startCalls++ }, func() { endCalls++ }
}
setOnTapped = func(func()) {}
backend := &systrayBackend{}
if err := backend.Start(BackendCallbacks{Ready: backend.Stop}); err != nil {
t.Fatalf("Start: %v", err)
}
if startCalls != 1 {
t.Fatalf("start calls = %d, want 1", startCalls)
}
if endCalls != 1 {
t.Fatalf("end calls = %d, want 1", endCalls)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB